mirror of
https://github.com/Canner/WrenAI.git
synced 2026-10-16 15:07:41 +08:00
feat(core): add wren-context-loader, the MDL adapter for Warble bindings
Warble is a context-neutral behavior framework: it probes a bound semantic layer through a narrow trait and knows no semantic format. Reading Wren MDL therefore does not belong inside it — a framework that ships an MDL reader has a Wren dependency in its core. The adapter moves here, so Wren depends on Warble and never the reverse. The crate reads a v5 wren project into Warble's Info projections and builds the semantic lineage DAG. It exposes both ways of handing that to Warble: MdlContext implements ContextLoader for an in-process host, and the wren-context-loader binary renders the same projection as a prepared-context document for a host that drives `warble` as a subprocess and so cannot pass a Rust trait object. Serialization is Warble's own, so the two ends cannot disagree about the format. Verified by compiling the same jaffle project down both paths — Warble reading the project natively, and Warble reading this crate's document — and diffing: the IRs are byte-identical. Sources build against the in-tree wren-core-base 0.2.0 unchanged. Test fixtures travel with the tests, minus the data files the adapter never reads. The warble dependency is pinned to a branch until the contract it needs is in a release.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
name: Context Loader
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'core/wren-context-loader/**'
|
||||
- 'core/wren-core-base/**'
|
||||
- '.github/workflows/context-loader-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'core/wren-context-loader/**'
|
||||
- 'core/wren-core-base/**'
|
||||
- '.github/workflows/context-loader-ci.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: core/wren-context-loader
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: fmt, clippy, test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
# `core/wren-core-base` is a path dependency, so a change there breaks this crate without
|
||||
# touching it — which is why this workflow watches that path too.
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
core/wren-context-loader/target/
|
||||
key: context-loader-${{ hashFiles('core/wren-context-loader/Cargo.toml') }}
|
||||
|
||||
- name: cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test
|
||||
Generated
+999
@@ -0,0 +1,999 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb-platform",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb-platform"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
||||
dependencies = [
|
||||
"jiff-tzdb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "recursive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e"
|
||||
dependencies = [
|
||||
"recursive-proc-macro-impl",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recursive-proc-macro-impl"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.1",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "sqlparser"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"recursive",
|
||||
"sqlparser_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparser_derive"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "warble"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/Canner/Warble?branch=feat%2Fprepared-context-binding#9de76ff93ef0490ef03fe0e4c732920fb5baf0a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wren-context-loader"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sqlparser",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"warble",
|
||||
"wren-core-base",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wren-core-base"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sqlparser",
|
||||
"wren-manifest-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wren-manifest-macro"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "wren-context-loader"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Reads a Wren MDL project into Warble's ContextLoader projection, so a Warble profile can bind a wren project without Warble depending on Wren."
|
||||
homepage = "https://getwren.ai"
|
||||
repository = "https://github.com/Canner/WrenAI"
|
||||
keywords = ["mdl", "semantic-layer", "lineage", "wren"]
|
||||
categories = ["database"]
|
||||
|
||||
[dependencies]
|
||||
# Pinned to a branch, not a release: `PreparedContext` and `prepared_document_from` are not in a
|
||||
# published warble version yet. Becomes a plain version requirement once that branch merges and
|
||||
# warble cuts a release — until then a branch dep is what keeps this reproducible off this machine.
|
||||
warble = { git = "https://github.com/Canner/Warble", branch = "feat/prepared-context-binding" }
|
||||
wren-core-base = { path = "../wren-core-base", version = "0.2.0" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
sqlparser = { version = "0.58", features = ["visitor"] }
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Parse **consumer artifacts** — the git-native files that consume the semantic layer — plus the
|
||||
//! SQL table-reference extraction shared with view lineage.
|
||||
//!
|
||||
//! Two consumer sources (both read by the host into [`crate::ProjectSources`], never from the
|
||||
//! filesystem here):
|
||||
//! - `knowledge/sql/<slug>.md` — the wren CLI's confirmed NL→SQL memory store. Each file is a YAML
|
||||
//! frontmatter block (`nl`, `sql`, `source`, `tags`); only `sql` matters for lineage.
|
||||
//! - `dashboards.yml` — a minimal declarative dashboard spec: `dashboards[].name` +
|
||||
//! `panels[].sql` (a raw query) or `panels[].cube` + `measures` (declared cube references).
|
||||
//!
|
||||
//! Table extraction parses SQL with `sqlparser` (GenericDialect) and collects every relation name;
|
||||
//! a statement that fails to parse is reported to the caller, who falls back to a whole-word text
|
||||
//! scan and records the degradation (no silent caps).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlparser::ast::{visit_relations, ObjectNamePart};
|
||||
use sqlparser::dialect::GenericDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
/// The lineage-relevant slice of a `knowledge/sql/<slug>.md` frontmatter.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KnowledgeFrontmatter {
|
||||
#[serde(default)]
|
||||
sql: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the confirmed query's SQL from a `knowledge/sql/<slug>.md` file. The file is YAML
|
||||
/// frontmatter between `---` fences (any body after the closing fence is ignored); a fence-less
|
||||
/// file is tried as bare YAML. Returns a human reason on failure so the caller can record it.
|
||||
pub(crate) fn knowledge_sql(contents: &str) -> Result<String, String> {
|
||||
let yaml = frontmatter_block(contents).unwrap_or(contents);
|
||||
let front: KnowledgeFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|e| format!("frontmatter is not valid yaml: {e}"))?;
|
||||
front
|
||||
.sql
|
||||
.filter(|sql| !sql.trim().is_empty())
|
||||
.ok_or_else(|| "frontmatter has no `sql:` field".to_string())
|
||||
}
|
||||
|
||||
/// The YAML between the first `---` fence and the next line starting with `---`, if the file uses
|
||||
/// frontmatter fences at all.
|
||||
fn frontmatter_block(contents: &str) -> Option<&str> {
|
||||
let rest = contents.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
rest.lines()
|
||||
.scan(0usize, |offset, line| {
|
||||
let start = *offset;
|
||||
*offset += line.len() + 1;
|
||||
Some((start, line))
|
||||
})
|
||||
.find(|(_, line)| line.trim_end() == "---")
|
||||
.map(|(start, _)| &rest[..start])
|
||||
}
|
||||
|
||||
/// The root `dashboards.yml` document.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct DashboardsFile {
|
||||
#[serde(default)]
|
||||
pub dashboards: Vec<DashboardSpec>,
|
||||
}
|
||||
|
||||
/// One dashboard: a named set of panels.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct DashboardSpec {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub panels: Vec<PanelSpec>,
|
||||
}
|
||||
|
||||
/// One panel — either a raw SQL query (`sql`, references *discovered* by parsing) or a declared
|
||||
/// cube reference (`cube` + `measures`, dangling if the target doesn't exist — deliberately loud,
|
||||
/// like a cube's `base_object`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct PanelSpec {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sql: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cube: Option<String>,
|
||||
#[serde(default)]
|
||||
pub measures: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_dashboards(yml: &str) -> Result<DashboardsFile, String> {
|
||||
serde_yaml::from_str(yml).map_err(|e| format!("dashboards.yml is not valid yaml: {e}"))
|
||||
}
|
||||
|
||||
/// Every relation (table-position) name referenced by `sql`, by parsing it with sqlparser's
|
||||
/// GenericDialect. Compound names keep only their last part (`main.orders` → `orders`), since MDL
|
||||
/// node names are unqualified. Returns `None` when the SQL does not parse — the caller decides the
|
||||
/// fallback (whole-word scan) and records the degradation.
|
||||
pub(crate) fn sql_table_names(sql: &str) -> Option<BTreeSet<String>> {
|
||||
let statements = Parser::parse_sql(&GenericDialect {}, sql).ok()?;
|
||||
let mut names = BTreeSet::new();
|
||||
let _: ControlFlow<()> = visit_relations(&statements, |relation| {
|
||||
if let Some(ObjectNamePart::Identifier(ident)) = relation.0.last() {
|
||||
names.insert(ident.value.clone());
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
Some(names)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn knowledge_sql_reads_frontmatter() {
|
||||
let md = "---\nnl: monthly revenue\nsql: SELECT status, COUNT(*) FROM orders GROUP BY 1\nsource: user\ntags:\n- source:enrich\n---\n";
|
||||
assert_eq!(
|
||||
knowledge_sql(md).unwrap(),
|
||||
"SELECT status, COUNT(*) FROM orders GROUP BY 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_sql_ignores_body_after_closing_fence() {
|
||||
let md = "---\nsql: SELECT 1\n---\n\nSome trailing notes: not yaml [at all\n";
|
||||
assert_eq!(knowledge_sql(md).unwrap(), "SELECT 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_sql_with_unterminated_fence_still_parses_as_bare_yaml() {
|
||||
// An opening `---` with no closing fence: `frontmatter_block` finds no fence pair, so the
|
||||
// whole file is tried as bare YAML — where `---` is just a document-start marker.
|
||||
let md = "---\nsql: SELECT 1\n";
|
||||
assert_eq!(knowledge_sql(md).unwrap(), "SELECT 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_sql_without_sql_field_is_an_error() {
|
||||
let md = "---\nnl: just a note\n---\n";
|
||||
assert!(knowledge_sql(md).unwrap_err().contains("no `sql:` field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_sql_bad_yaml_is_an_error() {
|
||||
let md = "---\nsql: [unclosed\n---\n";
|
||||
assert!(knowledge_sql(md).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_table_names_walks_joins_and_subqueries() {
|
||||
let names = sql_table_names(
|
||||
"SELECT c.country, SUM(o.total) FROM orders o \
|
||||
JOIN customers c ON o.customer_id = c.id \
|
||||
WHERE o.id IN (SELECT order_id FROM refunds) GROUP BY 1",
|
||||
)
|
||||
.unwrap();
|
||||
let expected: BTreeSet<String> = ["orders", "customers", "refunds"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(names, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sql_table_names_unqualifies_compound_names() {
|
||||
let names = sql_table_names("SELECT * FROM main.orders").unwrap();
|
||||
assert!(names.contains("orders"));
|
||||
assert!(!names.contains("main.orders"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_sql_returns_none() {
|
||||
assert!(sql_table_names("SELECT FROM WHERE ((( broken").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Project a wren [`Manifest`] into Warble's narrow Info types + infer measure additivity.
|
||||
//!
|
||||
//! The projection is deliberately **loose for existence, strict for semantics** (the Phase 2
|
||||
//! predicate-semantics decision): a queryable quantity is either a declared cube measure *or* a
|
||||
//! plain numeric column (so `has_metric` holds on cube-less projects), but additivity is only
|
||||
//! expressible for a declared measure — an implicit column carries `additivity: None`, which is
|
||||
//! what makes `metric_additive` unanswerable (a `can_answer=false` loud-fail) rather than silently
|
||||
//! `false`.
|
||||
|
||||
use warble::{Additivity, ContextLoader, DimensionInfo, LineageGraph, MetricInfo, ModelInfo};
|
||||
use wren_core_base::mdl::manifest::Manifest;
|
||||
|
||||
use crate::lineage;
|
||||
use crate::project::{assemble, LoadError, ProjectSources};
|
||||
|
||||
/// A `ContextLoader` backed by a wren MDL manifest. Built once from an assembled manifest; all
|
||||
/// accessors return borrowed, pre-projected data (pure, sans-IO).
|
||||
pub struct MdlContext {
|
||||
parseable: bool,
|
||||
parse_error: Option<String>,
|
||||
metrics: Vec<MetricInfo>,
|
||||
dimensions: Vec<DimensionInfo>,
|
||||
time_dimensions: Vec<DimensionInfo>,
|
||||
models: Vec<ModelInfo>,
|
||||
lineage: LineageGraph,
|
||||
lineage_diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
impl MdlContext {
|
||||
/// Build from assembled project sources. On assembly failure returns an *unparseable* context
|
||||
/// (rather than an error) so the compiler can evaluate `mdl_parseable`/`wren_project_exists`
|
||||
/// to `false` and loud-fail with a precondition message, per the sans-IO probe model. The
|
||||
/// underlying [`LoadError`] text is discarded here — use [`Self::try_from_sources`] (or catch
|
||||
/// the error and call [`Self::unparseable_with_error`]) when the caller wants it surfaced.
|
||||
pub fn from_sources(sources: &ProjectSources) -> Self {
|
||||
match assemble(sources) {
|
||||
Ok(loaded) => Self::from_manifest_and_consumers(&loaded.manifest, sources),
|
||||
Err(_) => Self::unparseable(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build from an assembled project, surfacing the assembly error to the host if it wants it.
|
||||
pub fn try_from_sources(sources: &ProjectSources) -> Result<Self, LoadError> {
|
||||
assemble(sources).map(|loaded| Self::from_manifest_and_consumers(&loaded.manifest, sources))
|
||||
}
|
||||
|
||||
/// [`Self::from_manifest`] plus the consumer extension: `knowledge/sql` confirmed queries and
|
||||
/// `dashboards.yml` specs from `sources` become `query:`/`dashboard:` lineage nodes. Consumer
|
||||
/// artifacts live outside the MDL manifest, which is why this needs the raw sources.
|
||||
fn from_manifest_and_consumers(manifest: &Manifest, sources: &ProjectSources) -> Self {
|
||||
let mut ctx = Self::from_manifest(manifest);
|
||||
lineage::extend_with_consumers(
|
||||
&mut ctx.lineage,
|
||||
manifest,
|
||||
sources,
|
||||
&mut ctx.lineage_diagnostics,
|
||||
);
|
||||
ctx
|
||||
}
|
||||
|
||||
/// An empty, non-parseable context (bound project missing or malformed), with no detail on
|
||||
/// *why* — used when the caller has no [`LoadError`] to hand (e.g. no wren project directory
|
||||
/// found at all).
|
||||
pub fn unparseable() -> Self {
|
||||
Self::unparseable_with_error(None)
|
||||
}
|
||||
|
||||
/// An empty, non-parseable context carrying the real assembly failure text (from
|
||||
/// [`Self::try_from_sources`]'s `Err`), so the host can surface *why* the bound project didn't
|
||||
/// parse instead of only the generic `mdl_parseable` floor message.
|
||||
pub fn unparseable_with_error(parse_error: Option<String>) -> Self {
|
||||
MdlContext {
|
||||
parseable: false,
|
||||
parse_error,
|
||||
metrics: Vec::new(),
|
||||
dimensions: Vec::new(),
|
||||
time_dimensions: Vec::new(),
|
||||
models: Vec::new(),
|
||||
lineage: LineageGraph::default(),
|
||||
lineage_diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a parsed manifest into the Info types + lineage.
|
||||
pub fn from_manifest(manifest: &Manifest) -> Self {
|
||||
let mut metrics = Vec::new();
|
||||
let mut dimensions = Vec::new();
|
||||
let mut time_dimensions = Vec::new();
|
||||
let mut models = Vec::new();
|
||||
|
||||
// Declared cube members: measures are metrics (additivity determinable); cube dimensions
|
||||
// and time-dimensions are dimensions.
|
||||
for cube in &manifest.cubes {
|
||||
for measure in &cube.measures {
|
||||
metrics.push(MetricInfo {
|
||||
name: measure.name.clone(),
|
||||
owner: cube.name.clone(),
|
||||
declared: true,
|
||||
additivity: Some(infer_additivity(&measure.expression)),
|
||||
});
|
||||
}
|
||||
for dim in &cube.dimensions {
|
||||
dimensions.push(DimensionInfo {
|
||||
name: dim.name.clone(),
|
||||
owner: cube.name.clone(),
|
||||
is_temporal: false,
|
||||
});
|
||||
}
|
||||
for tdim in &cube.time_dimensions {
|
||||
let d = DimensionInfo {
|
||||
name: tdim.name.clone(),
|
||||
owner: cube.name.clone(),
|
||||
is_temporal: true,
|
||||
};
|
||||
time_dimensions.push(d.clone());
|
||||
dimensions.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// Implicit column-level projection: numeric columns are queryable quantities (metrics);
|
||||
// categorical columns are groupable dimensions; date/timestamp columns are time dimensions.
|
||||
for model in &manifest.models {
|
||||
let mut has_timestamp = false;
|
||||
let mut column_names = Vec::new();
|
||||
for col in model.columns.iter().filter(|c| !c.is_hidden) {
|
||||
column_names.push(col.name.clone());
|
||||
// Relationship columns are navigation, not queryable values.
|
||||
if col.relationship.is_some() {
|
||||
continue;
|
||||
}
|
||||
if is_temporal_type(&col.r#type) {
|
||||
has_timestamp = true;
|
||||
let d = DimensionInfo {
|
||||
name: col.name.clone(),
|
||||
owner: model.name.clone(),
|
||||
is_temporal: true,
|
||||
};
|
||||
time_dimensions.push(d.clone());
|
||||
dimensions.push(d);
|
||||
} else if is_numeric_type(&col.r#type) {
|
||||
metrics.push(MetricInfo {
|
||||
name: col.name.clone(),
|
||||
owner: model.name.clone(),
|
||||
declared: false,
|
||||
additivity: None,
|
||||
});
|
||||
} else {
|
||||
// Textual / boolean / other → groupable dimension.
|
||||
dimensions.push(DimensionInfo {
|
||||
name: col.name.clone(),
|
||||
owner: model.name.clone(),
|
||||
is_temporal: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
models.push(ModelInfo {
|
||||
name: model.name.clone(),
|
||||
has_timestamp,
|
||||
columns: column_names,
|
||||
});
|
||||
}
|
||||
|
||||
let (lineage, lineage_diagnostics) = lineage::build(manifest);
|
||||
MdlContext {
|
||||
parseable: true,
|
||||
parse_error: None,
|
||||
metrics,
|
||||
dimensions,
|
||||
time_dimensions,
|
||||
models,
|
||||
lineage,
|
||||
lineage_diagnostics,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextLoader for MdlContext {
|
||||
fn is_parseable(&self) -> bool {
|
||||
self.parseable
|
||||
}
|
||||
fn parse_error(&self) -> Option<&str> {
|
||||
self.parse_error.as_deref()
|
||||
}
|
||||
fn metrics(&self) -> &[MetricInfo] {
|
||||
&self.metrics
|
||||
}
|
||||
fn dimensions(&self) -> &[DimensionInfo] {
|
||||
&self.dimensions
|
||||
}
|
||||
fn time_dimensions(&self) -> &[DimensionInfo] {
|
||||
&self.time_dimensions
|
||||
}
|
||||
fn models(&self) -> &[ModelInfo] {
|
||||
&self.models
|
||||
}
|
||||
fn lineage(&self) -> &LineageGraph {
|
||||
&self.lineage
|
||||
}
|
||||
fn lineage_diagnostics(&self) -> &[String] {
|
||||
&self.lineage_diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a measure's additivity from the leading aggregation of its expression.
|
||||
///
|
||||
/// Additive (delta attributes cleanly to per-dimension contributions that sum to the whole):
|
||||
/// `SUM`, non-distinct `COUNT`. Everything else — `AVG`/`MIN`/`MAX`/`MEDIAN`/`STDDEV`/`VARIANCE`,
|
||||
/// any `DISTINCT`, and any ratio (`/`) — is non-additive: decomposing it along dimensions can
|
||||
/// mislead. Unrecognized aggregations are treated as non-additive (conservative: never green-light
|
||||
/// a decomposition we cannot vouch for). `SemiAdditive` is not produced here — it needs
|
||||
/// knowledge-layer input (a later vocabulary batch).
|
||||
pub fn infer_additivity(expression: &str) -> Additivity {
|
||||
let upper = expression.to_uppercase();
|
||||
if upper.contains("DISTINCT") || upper.contains('/') {
|
||||
return Additivity::NonAdditive;
|
||||
}
|
||||
match leading_function(&upper).as_deref() {
|
||||
Some("SUM") | Some("COUNT") => Additivity::Additive,
|
||||
_ => Additivity::NonAdditive,
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading function name of an expression, e.g. `"SUM(amount)"` → `Some("SUM")`. Returns `None`
|
||||
/// when the expression is not a `fn(...)` call (a bare column reference).
|
||||
fn leading_function(upper_expr: &str) -> Option<String> {
|
||||
let open = upper_expr.find('(')?;
|
||||
let name = upper_expr[..open].trim();
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
fn normalize_type(t: &str) -> String {
|
||||
// Strip any precision/args, e.g. DECIMAL(10,2) → DECIMAL; VARCHAR(255) → VARCHAR.
|
||||
let base = t.split('(').next().unwrap_or(t).trim();
|
||||
base.to_uppercase()
|
||||
}
|
||||
|
||||
fn is_temporal_type(t: &str) -> bool {
|
||||
let t = normalize_type(t);
|
||||
t == "DATE" || t == "DATETIME" || t.starts_with("TIMESTAMP")
|
||||
}
|
||||
|
||||
fn is_numeric_type(t: &str) -> bool {
|
||||
matches!(
|
||||
normalize_type(t).as_str(),
|
||||
"INT"
|
||||
| "INTEGER"
|
||||
| "BIGINT"
|
||||
| "SMALLINT"
|
||||
| "TINYINT"
|
||||
| "DECIMAL"
|
||||
| "NUMERIC"
|
||||
| "DOUBLE"
|
||||
| "FLOAT"
|
||||
| "REAL"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn additivity_heuristic() {
|
||||
assert_eq!(infer_additivity("SUM(amount)"), Additivity::Additive);
|
||||
assert_eq!(infer_additivity("sum(amount)"), Additivity::Additive);
|
||||
assert_eq!(infer_additivity("COUNT(*)"), Additivity::Additive);
|
||||
assert_eq!(
|
||||
infer_additivity("COUNT(DISTINCT customer_id)"),
|
||||
Additivity::NonAdditive
|
||||
);
|
||||
assert_eq!(infer_additivity("AVG(amount)"), Additivity::NonAdditive);
|
||||
assert_eq!(infer_additivity("MIN(amount)"), Additivity::NonAdditive);
|
||||
assert_eq!(infer_additivity("MAX(amount)"), Additivity::NonAdditive);
|
||||
// ratio
|
||||
assert_eq!(infer_additivity("SUM(a) / SUM(b)"), Additivity::NonAdditive);
|
||||
// unrecognized → conservative non-additive
|
||||
assert_eq!(infer_additivity("weird(x)"), Additivity::NonAdditive);
|
||||
assert_eq!(infer_additivity("bare_column"), Additivity::NonAdditive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubeless_manifest_cannot_answer_metric_additive() {
|
||||
use warble::ContextLoader;
|
||||
use wren_core_base::mdl::manifest::Manifest;
|
||||
|
||||
// A cube-less manifest: numeric columns make has_metric hold, but no declared measure ⇒
|
||||
// additivity is not expressible ⇒ can_answer("metric_additive") is false.
|
||||
let json = r#"{
|
||||
"catalog":"wren","schema":"public",
|
||||
"models":[{"name":"orders","tableReference":{"schema":"main","table":"orders"},
|
||||
"columns":[{"name":"amount","type":"DOUBLE"},{"name":"status","type":"TEXT"}]}],
|
||||
"relationships":[],"cubes":[],"views":[]
|
||||
}"#;
|
||||
let manifest: Manifest = serde_json::from_str(json).unwrap();
|
||||
let ctx = MdlContext::from_manifest(&manifest);
|
||||
assert!(!ctx.metrics().is_empty(), "amount is an implicit metric");
|
||||
assert!(
|
||||
!ctx.can_answer("metric_additive"),
|
||||
"no declared measure ⇒ additivity unanswerable"
|
||||
);
|
||||
assert!(ctx
|
||||
.metrics()
|
||||
.iter()
|
||||
.all(|m| !m.declared && m.additivity.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_classification() {
|
||||
assert!(is_temporal_type("DATE"));
|
||||
assert!(is_temporal_type("timestamp"));
|
||||
assert!(is_temporal_type("TIMESTAMP WITH TIME ZONE"));
|
||||
assert!(!is_temporal_type("TEXT"));
|
||||
assert!(is_numeric_type("INT"));
|
||||
assert!(is_numeric_type("BIGINT"));
|
||||
assert!(is_numeric_type("DECIMAL(10,2)"));
|
||||
assert!(!is_numeric_type("TEXT"));
|
||||
assert!(!is_numeric_type("DATE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Reads a Wren MDL project into [`warble::ContextLoader`]'s projection.
|
||||
//!
|
||||
//! This crate is the Wren half of a deliberately one-directional relationship. Warble is a
|
||||
//! context-neutral behavior framework: it declares *what* a data agent does and probes a bound
|
||||
//! semantic layer through a narrow trait, without knowing any semantic format. Teaching it to read
|
||||
//! MDL would put a Wren dependency inside the framework, so the adapter lives here instead —
|
||||
//! Wren depends on Warble, never the reverse.
|
||||
//!
|
||||
//! Pipeline: read project files (host I/O) → [`assemble`] into a `wren-core-base` `Manifest` →
|
||||
//! [`MdlContext::from_manifest`] projects it to Warble's Info types and builds the semantic
|
||||
//! [`warble::LineageGraph`]. Parsing is pure and WASM-friendly (no DB, no async); query execution
|
||||
//! never enters this layer.
|
||||
//!
|
||||
//! # Two ways to hand the result to Warble
|
||||
//!
|
||||
//! - **In-process**: [`MdlContext`] implements [`warble::ContextLoader`], so a host that links
|
||||
//! Warble as a library passes it straight to a `ContextResolver`.
|
||||
//! - **Across a process boundary**: [`prepared_document`] renders the same projection as a
|
||||
//! prepared-context document, which `warble compile` reads via `kind: prepared`. This is the
|
||||
//! route for a host that drives the `warble` binary as a subprocess and therefore cannot hand
|
||||
//! over a Rust trait object.
|
||||
//!
|
||||
//! Both go through the same projection, so the two cannot disagree about what the project says.
|
||||
|
||||
mod consumers;
|
||||
mod introspect;
|
||||
mod lineage;
|
||||
mod project;
|
||||
|
||||
pub use introspect::{infer_additivity, MdlContext};
|
||||
pub use lineage::{cube_id, dashboard_id, dim_id, metric_id, model_id, query_id, rel_id, view_id};
|
||||
pub use project::{assemble, KnowledgeRules, LoadError, LoadedProject, ProjectSources};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use project::{read_knowledge_rules, read_project_dir};
|
||||
|
||||
/// Render a project's Warble projection as a prepared-context document.
|
||||
///
|
||||
/// The serialization itself belongs to Warble ([`warble::prepared_document_from`]), so this crate
|
||||
/// never hand-rolls the wire format and cannot drift from what `warble compile` reads.
|
||||
pub fn prepared_document(context: &MdlContext) -> Result<String, serde_json::Error> {
|
||||
warble::prepared_document_from(context)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Build Warble's semantic [`LineageGraph`] from a wren [`Manifest`], plus its extension with
|
||||
//! **consumer nodes** from a project's saved artifacts.
|
||||
//!
|
||||
//! Edges are oriented **upstream → downstream** (`from` depended on, `to` dependent), so a node's
|
||||
//! blast radius is its forward-reachable set (capability-model §7.1: `raw → models → relationships
|
||||
//! → metrics/dimensions → views → consumers`). The graph is built from structural references —
|
||||
//! relationships, cube base objects, view statements, consumer files — without executing SQL;
|
||||
//! SQL *text* is parsed (sqlparser) only to discover which relations it references, falling back
|
||||
//! to a whole-word scan (recorded in `diagnostics`) when a statement does not parse.
|
||||
//!
|
||||
//! Node id conventions (stable, queryable): `model:<name>`, `rel:<name>`, `cube:<name>`,
|
||||
//! `metric:<cube>.<measure>`, `dim:<cube>.<dimension>`, `view:<name>`, `query:<slug>`,
|
||||
//! `dashboard:<name>`.
|
||||
|
||||
use warble::{LineageEdge, LineageGraph, LineageKind, LineageNode};
|
||||
use wren_core_base::mdl::manifest::Manifest;
|
||||
|
||||
use crate::consumers;
|
||||
use crate::project::ProjectSources;
|
||||
|
||||
pub fn model_id(name: &str) -> String {
|
||||
format!("model:{name}")
|
||||
}
|
||||
pub fn rel_id(name: &str) -> String {
|
||||
format!("rel:{name}")
|
||||
}
|
||||
pub fn cube_id(name: &str) -> String {
|
||||
format!("cube:{name}")
|
||||
}
|
||||
pub fn metric_id(cube: &str, measure: &str) -> String {
|
||||
format!("metric:{cube}.{measure}")
|
||||
}
|
||||
pub fn dim_id(cube: &str, dimension: &str) -> String {
|
||||
format!("dim:{cube}.{dimension}")
|
||||
}
|
||||
pub fn view_id(name: &str) -> String {
|
||||
format!("view:{name}")
|
||||
}
|
||||
pub fn query_id(slug: &str) -> String {
|
||||
format!("query:{slug}")
|
||||
}
|
||||
pub fn dashboard_id(name: &str) -> String {
|
||||
format!("dashboard:{name}")
|
||||
}
|
||||
|
||||
fn push_node(nodes: &mut Vec<LineageNode>, id: String, kind: LineageKind) {
|
||||
if !nodes.iter().any(|n| n.id == id) {
|
||||
nodes.push(LineageNode { id, kind });
|
||||
}
|
||||
}
|
||||
|
||||
fn push_edge(edges: &mut Vec<LineageEdge>, from: String, to: String) {
|
||||
if !edges.iter().any(|e| e.from == from && e.to == to) {
|
||||
edges.push(LineageEdge { from, to });
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the lineage DAG from the manifest's structural references. References to models that do
|
||||
/// not exist (a dangling relationship member, cube base object, or view table) still produce an
|
||||
/// edge whose `from` endpoint is absent from the node set — so [`LineageGraph::is_resolvable`]
|
||||
/// detects them.
|
||||
///
|
||||
/// The second return value is the degradation diagnostics: one entry per view whose statement did
|
||||
/// not parse as SQL and was matched by whole-word scan instead (no silent caps).
|
||||
pub fn build(manifest: &Manifest) -> (LineageGraph, Vec<String>) {
|
||||
let mut nodes: Vec<LineageNode> = Vec::new();
|
||||
let mut edges: Vec<LineageEdge> = Vec::new();
|
||||
let mut diagnostics: Vec<String> = Vec::new();
|
||||
|
||||
let model_names: Vec<String> = manifest.models.iter().map(|m| m.name.clone()).collect();
|
||||
|
||||
for model in &manifest.models {
|
||||
push_node(&mut nodes, model_id(&model.name), LineageKind::Model);
|
||||
}
|
||||
|
||||
// Relationships depend on both joined models.
|
||||
for rel in &manifest.relationships {
|
||||
let rid = rel_id(&rel.name);
|
||||
push_node(&mut nodes, rid.clone(), LineageKind::Relationship);
|
||||
for member in &rel.models {
|
||||
push_edge(&mut edges, model_id(member), rid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cubes depend on their base object (a model or view); measures/dimensions depend on the cube.
|
||||
for cube in &manifest.cubes {
|
||||
let cid = cube_id(&cube.name);
|
||||
push_node(&mut nodes, cid.clone(), LineageKind::Cube);
|
||||
push_edge(&mut edges, model_id(&cube.base_object), cid.clone());
|
||||
for measure in &cube.measures {
|
||||
let mid = metric_id(&cube.name, &measure.name);
|
||||
push_node(&mut nodes, mid.clone(), LineageKind::Metric);
|
||||
push_edge(&mut edges, cid.clone(), mid);
|
||||
}
|
||||
let dim_names = cube
|
||||
.dimensions
|
||||
.iter()
|
||||
.map(|d| d.name.clone())
|
||||
.chain(cube.time_dimensions.iter().map(|d| d.name.clone()));
|
||||
for dim_name in dim_names {
|
||||
let did = dim_id(&cube.name, &dim_name);
|
||||
push_node(&mut nodes, did.clone(), LineageKind::Dimension);
|
||||
push_edge(&mut edges, cid.clone(), did);
|
||||
}
|
||||
}
|
||||
|
||||
// Views depend on every model their statement references. Discovered by SQL parse; a
|
||||
// statement that does not parse degrades to the whole-word scan, recorded honestly.
|
||||
for view in &manifest.views {
|
||||
let vid = view_id(&view.name);
|
||||
push_node(&mut nodes, vid.clone(), LineageKind::View);
|
||||
for model_name in referenced_names(&view.statement, &model_names, &vid, &mut diagnostics) {
|
||||
push_edge(&mut edges, model_id(&model_name), vid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(LineageGraph { nodes, edges }, diagnostics)
|
||||
}
|
||||
|
||||
/// Extend an already-built graph with **consumer nodes** from the project's saved artifacts:
|
||||
/// `knowledge/sql/<slug>.md` confirmed queries (→ `query:<slug>`) and `dashboards.yml` specs
|
||||
/// (→ `dashboard:<name>`). This is what makes a metric no longer a leaf — the gate can now say
|
||||
/// "this change reaches N dashboards".
|
||||
///
|
||||
/// Reference semantics mirror the rest of the graph:
|
||||
/// - **Discovered** references (SQL text, of a query or a `panels[].sql`) only bind to nodes that
|
||||
/// exist — an unknown relation name (a CTE, a raw table) produces nothing.
|
||||
/// - **Declared** references (`panels[].cube` + `measures`) always produce an edge; naming a cube
|
||||
/// or measure that does not exist leaves a dangling edge that `is_resolvable` flags, exactly
|
||||
/// like a dangling cube `base_object`.
|
||||
///
|
||||
/// A malformed consumer file is skipped, and every skip or SQL-parse fallback appends a
|
||||
/// human-readable entry to `diagnostics` (no silent caps).
|
||||
pub fn extend_with_consumers(
|
||||
graph: &mut LineageGraph,
|
||||
manifest: &Manifest,
|
||||
sources: &ProjectSources,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) {
|
||||
let model_names: Vec<String> = manifest.models.iter().map(|m| m.name.clone()).collect();
|
||||
let view_names: Vec<String> = manifest.views.iter().map(|v| v.name.clone()).collect();
|
||||
|
||||
for (slug, contents) in &sources.knowledge_sql_mds {
|
||||
let qid = query_id(slug);
|
||||
let sql = match consumers::knowledge_sql(contents) {
|
||||
Ok(sql) => sql,
|
||||
Err(reason) => {
|
||||
diagnostics.push(format!("knowledge/sql/{slug}.md skipped: {reason}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
push_node(&mut graph.nodes, qid.clone(), LineageKind::Query);
|
||||
link_sql_consumer(
|
||||
graph,
|
||||
manifest,
|
||||
&model_names,
|
||||
&view_names,
|
||||
&qid,
|
||||
&sql,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
let Some(dashboards_yml) = &sources.dashboards_yml else {
|
||||
return;
|
||||
};
|
||||
let parsed = match consumers::parse_dashboards(dashboards_yml) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(reason) => {
|
||||
diagnostics.push(format!("dashboards.yml skipped: {reason}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
for dashboard in &parsed.dashboards {
|
||||
let did = dashboard_id(&dashboard.name);
|
||||
push_node(&mut graph.nodes, did.clone(), LineageKind::Dashboard);
|
||||
for (index, panel) in dashboard.panels.iter().enumerate() {
|
||||
let panel_label = panel
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("panel {}", index + 1));
|
||||
if panel.sql.is_none() && panel.cube.is_none() {
|
||||
diagnostics.push(format!(
|
||||
"dashboard '{}' {panel_label} skipped: declares neither `sql` nor `cube`",
|
||||
dashboard.name
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some(sql) = &panel.sql {
|
||||
link_sql_consumer(
|
||||
graph,
|
||||
manifest,
|
||||
&model_names,
|
||||
&view_names,
|
||||
&did,
|
||||
sql,
|
||||
diagnostics,
|
||||
);
|
||||
}
|
||||
if let Some(cube) = &panel.cube {
|
||||
// Declared references: emitted even when the target is missing (dangling → loud).
|
||||
push_edge(&mut graph.edges, cube_id(cube), did.clone());
|
||||
for measure in &panel.measures {
|
||||
push_edge(&mut graph.edges, metric_id(cube, measure), did.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add edges from every semantic node a consumer's SQL references to the consumer. Relations are
|
||||
/// matched against models, views, and cubes; a referenced cube additionally links the measures and
|
||||
/// dimensions the SQL mentions (whole-word), so `metric → consumer` edges exist for cube queries.
|
||||
fn link_sql_consumer(
|
||||
graph: &mut LineageGraph,
|
||||
manifest: &Manifest,
|
||||
model_names: &[String],
|
||||
view_names: &[String],
|
||||
consumer: &str,
|
||||
sql: &str,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) {
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
candidates.extend(model_names.iter().cloned());
|
||||
candidates.extend(view_names.iter().cloned());
|
||||
candidates.extend(manifest.cubes.iter().map(|c| c.name.clone()));
|
||||
|
||||
for name in referenced_names(sql, &candidates, consumer, diagnostics) {
|
||||
if model_names.contains(&name) {
|
||||
push_edge(&mut graph.edges, model_id(&name), consumer.to_string());
|
||||
} else if view_names.contains(&name) {
|
||||
push_edge(&mut graph.edges, view_id(&name), consumer.to_string());
|
||||
} else if let Some(cube) = manifest.cubes.iter().find(|c| c.name == name) {
|
||||
push_edge(&mut graph.edges, cube_id(&name), consumer.to_string());
|
||||
for measure in &cube.measures {
|
||||
if statement_references(sql, &measure.name) {
|
||||
push_edge(
|
||||
&mut graph.edges,
|
||||
metric_id(&cube.name, &measure.name),
|
||||
consumer.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let dim_names = cube
|
||||
.dimensions
|
||||
.iter()
|
||||
.map(|d| d.name.as_str())
|
||||
.chain(cube.time_dimensions.iter().map(|d| d.name.as_str()));
|
||||
for dim_name in dim_names {
|
||||
if statement_references(sql, dim_name) {
|
||||
push_edge(
|
||||
&mut graph.edges,
|
||||
dim_id(&cube.name, dim_name),
|
||||
consumer.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of `candidates` that `sql` references as a relation. Parses the SQL and intersects
|
||||
/// its relation names with `candidates`; when the SQL does not parse, degrades to the whole-word
|
||||
/// scan over every candidate and records the degradation against `node` in `diagnostics`.
|
||||
fn referenced_names(
|
||||
sql: &str,
|
||||
candidates: &[String],
|
||||
node: &str,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
match consumers::sql_table_names(sql) {
|
||||
Some(relations) => candidates
|
||||
.iter()
|
||||
.filter(|name| relations.contains(*name))
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => {
|
||||
diagnostics.push(format!(
|
||||
"{node}: statement did not parse as SQL; matched tables by whole-word scan"
|
||||
));
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|name| statement_references(sql, name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Naive whole-word containment check: does `statement` reference `name` as a standalone token?
|
||||
/// Avoids the false positive where `orders` matches inside `raw_orders` by requiring
|
||||
/// non-identifier boundaries on both sides. The fallback matcher when SQL does not parse.
|
||||
fn statement_references(statement: &str, name: &str) -> bool {
|
||||
let bytes = statement.as_bytes();
|
||||
let name_bytes = name.as_bytes();
|
||||
if name_bytes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
|
||||
let mut start = 0;
|
||||
while let Some(pos) = statement[start..].find(name) {
|
||||
let abs = start + pos;
|
||||
let before_ok = abs == 0 || !is_ident(bytes[abs - 1]);
|
||||
let after_idx = abs + name_bytes.len();
|
||||
let after_ok = after_idx >= bytes.len() || !is_ident(bytes[after_idx]);
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
start = abs + name_bytes.len();
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Render a wren project as a Warble prepared-context document.
|
||||
//!
|
||||
//! ```text
|
||||
//! wren-context-loader <project-dir> [-o <out.json>]
|
||||
//! ```
|
||||
//!
|
||||
//! Writes to stdout when `-o` is omitted. A host runs this before `warble compile` and binds the
|
||||
//! output with `kind: prepared`, which is how a Warble profile binds a wren project without Warble
|
||||
//! itself depending on Wren.
|
||||
//!
|
||||
//! **An unreadable project is still a document.** Failing to assemble the MDL is a fact *about the
|
||||
//! project* that Warble's `mdl_parseable` precondition exists to report, so it is written as
|
||||
//! `parseable: false` with the assembly error attached. Only a genuinely unusable invocation — a
|
||||
//! path that is not a directory, an unwritable output file — exits non-zero.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use wren_context_loader::{prepared_document, read_project_dir, MdlContext};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let (project_dir, out_path) = match parse_args(&args) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(message) => {
|
||||
eprintln!("{message}");
|
||||
eprintln!("usage: wren-context-loader <project-dir> [-o <out.json>]");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let context = match load(&project_dir) {
|
||||
Ok(context) => context,
|
||||
Err(message) => {
|
||||
eprintln!("{message}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
let document = match prepared_document(&context) {
|
||||
Ok(document) => document,
|
||||
Err(e) => {
|
||||
eprintln!("failed to serialize the context projection: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
match out_path {
|
||||
Some(path) => {
|
||||
if let Err(e) = std::fs::write(&path, document) {
|
||||
eprintln!("failed to write {}: {e}", path.display());
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
None => println!("{document}"),
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn parse_args(args: &[String]) -> Result<(PathBuf, Option<PathBuf>), String> {
|
||||
let mut project_dir: Option<PathBuf> = None;
|
||||
let mut out_path: Option<PathBuf> = None;
|
||||
let mut rest = args.iter();
|
||||
while let Some(arg) = rest.next() {
|
||||
match arg.as_str() {
|
||||
"-o" | "--out" => {
|
||||
let value = rest.next().ok_or_else(|| format!("{arg} needs a path"))?;
|
||||
out_path = Some(PathBuf::from(value));
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown flag '{other}'")),
|
||||
other => {
|
||||
if project_dir.is_some() {
|
||||
return Err(format!("unexpected second project directory '{other}'"));
|
||||
}
|
||||
project_dir = Some(PathBuf::from(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
let project_dir = project_dir.ok_or_else(|| "a project directory is required".to_string())?;
|
||||
Ok((project_dir, out_path))
|
||||
}
|
||||
|
||||
/// Read and assemble the project, turning an assembly failure into an unparseable context rather
|
||||
/// than an error: that distinction is what `mdl_parseable` reports downstream.
|
||||
fn load(project_dir: &Path) -> Result<MdlContext, String> {
|
||||
if !project_dir.is_dir() {
|
||||
return Err(format!("{} is not a directory", project_dir.display()));
|
||||
}
|
||||
let sources = read_project_dir(project_dir)
|
||||
.map_err(|e| format!("failed to read {}: {e}", project_dir.display()))?;
|
||||
let Some(sources) = sources else {
|
||||
// No `wren_project.yml`: not a wren project at all. Still a document — a profile bound
|
||||
// here must fail its `mdl_parseable` precondition, not fail to compile.
|
||||
return Ok(MdlContext::unparseable_with_error(Some(format!(
|
||||
"{} holds no wren_project.yml",
|
||||
project_dir.display()
|
||||
))));
|
||||
};
|
||||
Ok(match MdlContext::try_from_sources(&sources) {
|
||||
Ok(context) => context,
|
||||
Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Assemble an on-disk **wren project** (the split-file, `schema_version: 2` layout: a
|
||||
//! `wren_project.yml` + per-model `models/<name>/metadata.yml` + `relationships.yml` + optional
|
||||
//! `cubes.yml` + `views/<name>/{metadata,sql}.yml`) into a canonical [`wren_core_base::mdl::Manifest`].
|
||||
//!
|
||||
//! The engine-consumed MDL format is the single camelCase `Manifest`; a wren *project* is the
|
||||
//! authored, split, snake_case form the `wren` CLI compiles into that manifest. This loader does
|
||||
//! the structural slice of that compilation the compiler needs for introspection (models, columns,
|
||||
//! relationships, cubes, views) — it does not expand `ref_sql` or resolve calculated columns
|
||||
//! (execution concerns that never enter the sans-IO front-end). Parse is pure over already-read
|
||||
//! bytes; the host owns the actual file reads.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use thiserror::Error;
|
||||
use wren_core_base::mdl::manifest::Manifest;
|
||||
|
||||
/// A wren project assembled into a canonical MDL manifest.
|
||||
pub struct LoadedProject {
|
||||
pub manifest: Manifest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadError {
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
fn msg(s: impl Into<String>) -> LoadError {
|
||||
LoadError::Message(s.into())
|
||||
}
|
||||
|
||||
// --- on-disk (authored, snake_case) shapes ------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProjectFile {
|
||||
#[serde(default)]
|
||||
catalog: Option<String>,
|
||||
#[serde(default)]
|
||||
schema: Option<String>,
|
||||
#[serde(default)]
|
||||
data_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelFile {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
table_reference: Option<serde_yaml::Value>,
|
||||
#[serde(default)]
|
||||
base_object: Option<String>,
|
||||
#[serde(default)]
|
||||
ref_sql: Option<String>,
|
||||
#[serde(default)]
|
||||
primary_key: Option<serde_yaml::Value>,
|
||||
#[serde(default)]
|
||||
columns: Vec<ColumnFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ColumnFile {
|
||||
name: String,
|
||||
r#type: String,
|
||||
#[serde(default)]
|
||||
not_null: Option<bool>,
|
||||
#[serde(default)]
|
||||
relationship: Option<String>,
|
||||
#[serde(default)]
|
||||
is_calculated: Option<bool>,
|
||||
#[serde(default)]
|
||||
expression: Option<String>,
|
||||
#[serde(default)]
|
||||
is_hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RelationshipFile {
|
||||
name: String,
|
||||
models: Vec<String>,
|
||||
join_type: String,
|
||||
condition: String,
|
||||
}
|
||||
|
||||
/// `relationships.yml` has two authored shapes: the original bare list, and the
|
||||
/// `relationships:` keyed mapping that the wren CLI (project schema_version 5) scaffolds
|
||||
/// and requires. Accept both; rejecting the keyed form would silently strip every join
|
||||
/// from any project authored with the current CLI.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RelationshipsDoc {
|
||||
Bare(Vec<RelationshipFile>),
|
||||
Keyed {
|
||||
relationships: Vec<RelationshipFile>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RelationshipsDoc {
|
||||
fn into_vec(self) -> Vec<RelationshipFile> {
|
||||
match self {
|
||||
RelationshipsDoc::Bare(v) => v,
|
||||
RelationshipsDoc::Keyed { relationships } => relationships,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CubeFile {
|
||||
name: String,
|
||||
base_object: String,
|
||||
#[serde(default)]
|
||||
measures: Vec<CubeMemberFile>,
|
||||
#[serde(default)]
|
||||
dimensions: Vec<CubeMemberFile>,
|
||||
#[serde(default)]
|
||||
time_dimensions: Vec<CubeMemberFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CubeMemberFile {
|
||||
name: String,
|
||||
expression: String,
|
||||
r#type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ViewMetaFile {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ViewSqlFile {
|
||||
statement: String,
|
||||
}
|
||||
|
||||
// --- native host convenience ---------------------------------------------------------------------
|
||||
|
||||
/// Read a wren project directory into [`ProjectSources`] (native host only; the pure `assemble`
|
||||
/// path is what a WASM host feeds directly). Returns `None` if the directory has no
|
||||
/// `wren_project.yml` — i.e. it is not a wren project — so the caller can build an unparseable
|
||||
/// context. Model, cube, and view sub-directories are read in sorted order for deterministic
|
||||
/// output.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_project_dir(dir: &std::path::Path) -> std::io::Result<Option<ProjectSources>> {
|
||||
use std::fs;
|
||||
|
||||
let wren_project_path = dir.join("wren_project.yml");
|
||||
if !wren_project_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let wren_project_yml = fs::read_to_string(&wren_project_path)?;
|
||||
|
||||
let mut model_ymls = Vec::new();
|
||||
let models_dir = dir.join("models");
|
||||
if models_dir.is_dir() {
|
||||
let mut entries: Vec<_> = fs::read_dir(&models_dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for model_dir in entries {
|
||||
let meta = model_dir.join("metadata.yml");
|
||||
if meta.is_file() {
|
||||
model_ymls.push(fs::read_to_string(meta)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let relationships_yml = read_if_exists(&dir.join("relationships.yml"))?;
|
||||
let cubes_yml = read_if_exists(&dir.join("cubes.yml"))?;
|
||||
|
||||
let mut cube_ymls = Vec::new();
|
||||
let cubes_dir = dir.join("cubes");
|
||||
if cubes_dir.is_dir() {
|
||||
let mut entries: Vec<_> = fs::read_dir(&cubes_dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for cube_dir in entries {
|
||||
let meta = cube_dir.join("metadata.yml");
|
||||
if meta.is_file() {
|
||||
cube_ymls.push(fs::read_to_string(meta)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut views = Vec::new();
|
||||
let views_dir = dir.join("views");
|
||||
if views_dir.is_dir() {
|
||||
let mut entries: Vec<_> = fs::read_dir(&views_dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for view_dir in entries {
|
||||
let meta = view_dir.join("metadata.yml");
|
||||
let sql = view_dir.join("sql.yml");
|
||||
if meta.is_file() && sql.is_file() {
|
||||
views.push((fs::read_to_string(meta)?, fs::read_to_string(sql)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut knowledge_sql_mds = Vec::new();
|
||||
let knowledge_sql_dir = dir.join("knowledge").join("sql");
|
||||
if knowledge_sql_dir.is_dir() {
|
||||
let mut entries: Vec<_> = fs::read_dir(&knowledge_sql_dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
|
||||
.collect();
|
||||
entries.sort();
|
||||
for md in entries {
|
||||
let slug = md
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
knowledge_sql_mds.push((slug, fs::read_to_string(&md)?));
|
||||
}
|
||||
}
|
||||
|
||||
let dashboards_yml = read_if_exists(&dir.join("dashboards.yml"))?;
|
||||
|
||||
Ok(Some(ProjectSources {
|
||||
wren_project_yml,
|
||||
model_ymls,
|
||||
relationships_yml,
|
||||
cubes_yml,
|
||||
cube_ymls,
|
||||
views,
|
||||
knowledge_sql_mds,
|
||||
dashboards_yml,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read the business-rule slice used by `wren context instructions`, without invoking `wren`.
|
||||
///
|
||||
/// The native host owns these filesystem reads; dispatchers receive only the normalized string.
|
||||
/// Current `knowledge/rules/*.md` files are sorted by path and concatenated first, followed by the
|
||||
/// legacy root `instructions.md` when present. Empty files contribute nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_knowledge_rules(dir: &std::path::Path) -> std::io::Result<KnowledgeRules> {
|
||||
use std::fs;
|
||||
|
||||
let rules_dir = dir.join("knowledge").join("rules");
|
||||
let mut paths = if rules_dir.is_dir() {
|
||||
fs::read_dir(&rules_dir)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "md"))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
paths.sort();
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for path in paths {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
if !contents.trim().is_empty() {
|
||||
parts.push(contents.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let legacy_path = dir.join("instructions.md");
|
||||
let used_legacy = legacy_path.is_file();
|
||||
if used_legacy {
|
||||
let contents = fs::read_to_string(legacy_path)?;
|
||||
if !contents.trim().is_empty() {
|
||||
parts.push(contents.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(KnowledgeRules {
|
||||
content: parts.join("\n\n"),
|
||||
used_legacy,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalized business rules loaded by a native host for dispatch-time injection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KnowledgeRules {
|
||||
pub content: String,
|
||||
pub used_legacy: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_if_exists(path: &std::path::Path) -> std::io::Result<Option<String>> {
|
||||
if path.is_file() {
|
||||
Ok(Some(std::fs::read_to_string(path)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// --- assembly -----------------------------------------------------------------------------------
|
||||
|
||||
/// The raw file contents of a wren project, read by the host. `assemble` operates purely over
|
||||
/// these bytes (WASM-friendly); the native [`read_project_dir`] convenience fills them from disk.
|
||||
pub struct ProjectSources {
|
||||
pub wren_project_yml: String,
|
||||
pub model_ymls: Vec<String>,
|
||||
pub relationships_yml: Option<String>,
|
||||
pub cubes_yml: Option<String>,
|
||||
/// Per-cube `cubes/<name>/metadata.yml` contents (wren CLI layout). Used only when
|
||||
/// `cubes_yml` is `None` — a root `cubes.yml` takes precedence.
|
||||
pub cube_ymls: Vec<String>,
|
||||
/// Each view as `(metadata.yml, sql.yml)` contents.
|
||||
pub views: Vec<(String, String)>,
|
||||
/// Confirmed saved queries from the wren CLI's `knowledge/sql/<slug>.md` store, as
|
||||
/// `(slug, file contents)`. Consumer sources: they never enter the MDL manifest — they only
|
||||
/// enrich the lineage graph with `query:<slug>` consumer nodes.
|
||||
pub knowledge_sql_mds: Vec<(String, String)>,
|
||||
/// The root `dashboards.yml` contents (the minimal declarative dashboard-spec convention:
|
||||
/// `dashboards[].name` + `panels[].sql` or `panels[].cube`+`measures`). Consumer source, like
|
||||
/// `knowledge_sql_mds`.
|
||||
pub dashboards_yml: Option<String>,
|
||||
}
|
||||
|
||||
/// Assemble read project sources into a canonical [`Manifest`]. Returns a [`LoadError`] if any file
|
||||
/// fails to parse or the assembled manifest is not a valid MDL manifest.
|
||||
pub fn assemble(sources: &ProjectSources) -> Result<LoadedProject, LoadError> {
|
||||
let project: ProjectFile = serde_yaml::from_str(&sources.wren_project_yml)
|
||||
.map_err(|e| msg(format!("wren_project.yml: {e}")))?;
|
||||
|
||||
let mut models = Vec::with_capacity(sources.model_ymls.len());
|
||||
for yml in &sources.model_ymls {
|
||||
let m: ModelFile = serde_yaml::from_str(yml).map_err(|e| msg(format!("model: {e}")))?;
|
||||
models.push(model_to_json(m)?);
|
||||
}
|
||||
|
||||
let relationships = match &sources.relationships_yml {
|
||||
Some(yml) => {
|
||||
// A project with no relationships (e.g. a single-table onboarding scaffolded by the
|
||||
// wren CLI) ships a comment-only / empty relationships.yml, which parses to YAML null
|
||||
// and matches neither RelationshipsDoc variant. Treat an empty/null document as "no
|
||||
// relationships" rather than failing the whole project's parse.
|
||||
let is_empty = serde_yaml::from_str::<serde_yaml::Value>(yml)
|
||||
.map(|v| v.is_null())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
Vec::new()
|
||||
} else {
|
||||
let doc: RelationshipsDoc = serde_yaml::from_str(yml)
|
||||
.map_err(|e| msg(format!("relationships.yml: {e}")))?;
|
||||
doc.into_vec()
|
||||
.into_iter()
|
||||
.map(relationship_to_json)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// A root `cubes.yml` (bare list) wins when present; otherwise fall back to the wren CLI's
|
||||
// per-cube layout (`cubes/<name>/metadata.yml`, one cube per file). Never both, so a project
|
||||
// that keeps a root mirror alongside the directory doesn't get every cube twice.
|
||||
let cubes = match &sources.cubes_yml {
|
||||
Some(yml) => {
|
||||
let cubes: Vec<CubeFile> =
|
||||
serde_yaml::from_str(yml).map_err(|e| msg(format!("cubes.yml: {e}")))?;
|
||||
cubes.into_iter().map(cube_to_json).collect()
|
||||
}
|
||||
None => sources
|
||||
.cube_ymls
|
||||
.iter()
|
||||
.map(|yml| {
|
||||
let cube: CubeFile =
|
||||
serde_yaml::from_str(yml).map_err(|e| msg(format!("cube metadata: {e}")))?;
|
||||
Ok(cube_to_json(cube))
|
||||
})
|
||||
.collect::<Result<Vec<_>, LoadError>>()?,
|
||||
};
|
||||
|
||||
let mut views = Vec::with_capacity(sources.views.len());
|
||||
for (meta_yml, sql_yml) in &sources.views {
|
||||
let meta: ViewMetaFile =
|
||||
serde_yaml::from_str(meta_yml).map_err(|e| msg(format!("view metadata: {e}")))?;
|
||||
let sql: ViewSqlFile =
|
||||
serde_yaml::from_str(sql_yml).map_err(|e| msg(format!("view sql: {e}")))?;
|
||||
views.push(serde_json::json!({ "name": meta.name, "statement": sql.statement }));
|
||||
}
|
||||
|
||||
let mut manifest_json = serde_json::Map::new();
|
||||
if let Some(c) = project.catalog {
|
||||
manifest_json.insert("catalog".into(), serde_json::Value::String(c));
|
||||
}
|
||||
if let Some(s) = project.schema {
|
||||
manifest_json.insert("schema".into(), serde_json::Value::String(s));
|
||||
}
|
||||
if let Some(ds) = project.data_source {
|
||||
manifest_json.insert("dataSource".into(), serde_json::Value::String(ds));
|
||||
}
|
||||
manifest_json.insert("models".into(), serde_json::Value::Array(models));
|
||||
manifest_json.insert(
|
||||
"relationships".into(),
|
||||
serde_json::Value::Array(relationships),
|
||||
);
|
||||
manifest_json.insert("cubes".into(), serde_json::Value::Array(cubes));
|
||||
manifest_json.insert("views".into(), serde_json::Value::Array(views));
|
||||
|
||||
// `catalog`/`schema` are required by the Manifest schema; default them so a minimal project
|
||||
// (test fixtures) still assembles rather than failing on a missing namespace field.
|
||||
manifest_json
|
||||
.entry("catalog")
|
||||
.or_insert_with(|| serde_json::Value::String("wren".into()));
|
||||
manifest_json
|
||||
.entry("schema")
|
||||
.or_insert_with(|| serde_json::Value::String("public".into()));
|
||||
|
||||
let manifest: Manifest = serde_json::from_value(serde_json::Value::Object(manifest_json))
|
||||
.map_err(|e| msg(format!("assembled manifest is not valid MDL: {e}")))?;
|
||||
manifest
|
||||
.validate_layout_version()
|
||||
.map_err(|e| msg(e.to_string()))?;
|
||||
Ok(LoadedProject { manifest })
|
||||
}
|
||||
|
||||
fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
|
||||
serde_json::to_value(v).map_err(|e| msg(format!("value conversion: {e}")))
|
||||
}
|
||||
|
||||
/// Pad a partial `table_reference` mapping so `wren-core-base`'s `TableReference {
|
||||
/// catalog: Option<String>, schema: Option<String>, table: Option<String> }` custom deserializer
|
||||
/// accepts it.
|
||||
///
|
||||
/// The `wren generate-mdl` CLI emits file-backed sources (duckdb/csv/local_file) with a bare
|
||||
/// `table_reference: { table: /abs/path.parquet }` — no `catalog`/`schema` keys at all. Even
|
||||
/// though those fields are `Option<String>`, `wren-core-base`'s custom serde module has no
|
||||
/// `#[serde(default)]` on them, so a wholly-absent key is a hard "missing field `catalog`"
|
||||
/// deserialize error rather than defaulting to `None`. Filling in the missing keys as explicit
|
||||
/// JSON `null` (which `Option<String>` always deserializes as `None`, with no `default` attribute
|
||||
/// needed) makes a bare `{table: ...}` object parse the same as a fully-specified one.
|
||||
///
|
||||
/// Only a YAML *mapping* is padded. The CLI's other authored shapes for this field — a dotted
|
||||
/// string (`catalog.schema.table`) or a bare table-name string — go through a different (string)
|
||||
/// deserialization path on the `wren-core-base` side and never hit the missing-field case, so
|
||||
/// they pass through [`yaml_to_json`] unchanged.
|
||||
fn normalize_table_reference(tref: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
|
||||
let mut json = yaml_to_json(tref)?;
|
||||
if let serde_json::Value::Object(obj) = &mut json {
|
||||
for key in ["catalog", "schema", "table"] {
|
||||
obj.entry(key).or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
fn model_to_json(m: ModelFile) -> Result<serde_json::Value, LoadError> {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("name".into(), serde_json::Value::String(m.name));
|
||||
if let Some(tref) = m.table_reference {
|
||||
obj.insert("tableReference".into(), normalize_table_reference(tref)?);
|
||||
}
|
||||
if let Some(base) = m.base_object {
|
||||
obj.insert("baseObject".into(), serde_json::Value::String(base));
|
||||
}
|
||||
if let Some(sql) = m.ref_sql {
|
||||
obj.insert("refSql".into(), serde_json::Value::String(sql));
|
||||
}
|
||||
if let Some(pk) = m.primary_key {
|
||||
obj.insert("primaryKey".into(), yaml_to_json(pk)?);
|
||||
}
|
||||
let columns: Vec<serde_json::Value> = m.columns.into_iter().map(column_to_json).collect();
|
||||
obj.insert("columns".into(), serde_json::Value::Array(columns));
|
||||
Ok(serde_json::Value::Object(obj))
|
||||
}
|
||||
|
||||
fn column_to_json(c: ColumnFile) -> serde_json::Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert("name".into(), serde_json::Value::String(c.name));
|
||||
obj.insert("type".into(), serde_json::Value::String(c.r#type));
|
||||
if let Some(b) = c.not_null {
|
||||
obj.insert("notNull".into(), serde_json::Value::Bool(b));
|
||||
}
|
||||
if let Some(rel) = c.relationship {
|
||||
obj.insert("relationship".into(), serde_json::Value::String(rel));
|
||||
}
|
||||
if let Some(b) = c.is_calculated {
|
||||
obj.insert("isCalculated".into(), serde_json::Value::Bool(b));
|
||||
}
|
||||
if let Some(expr) = c.expression {
|
||||
obj.insert("expression".into(), serde_json::Value::String(expr));
|
||||
}
|
||||
if let Some(b) = c.is_hidden {
|
||||
obj.insert("isHidden".into(), serde_json::Value::Bool(b));
|
||||
}
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
fn relationship_to_json(r: RelationshipFile) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": r.name,
|
||||
"models": r.models,
|
||||
"joinType": r.join_type,
|
||||
"condition": r.condition,
|
||||
})
|
||||
}
|
||||
|
||||
fn cube_member_to_json(m: CubeMemberFile) -> serde_json::Value {
|
||||
serde_json::json!({ "name": m.name, "expression": m.expression, "type": m.r#type })
|
||||
}
|
||||
|
||||
fn cube_to_json(c: CubeFile) -> serde_json::Value {
|
||||
// `hierarchies` is a BTreeMap in the Manifest; an empty map keeps deterministic output.
|
||||
let empty: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
serde_json::json!({
|
||||
"name": c.name,
|
||||
"baseObject": c.base_object,
|
||||
"measures": c.measures.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
|
||||
"dimensions": c.dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
|
||||
"timeDimensions": c.time_dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
|
||||
"hierarchies": empty,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Guards the `wren generate-mdl` CLI's file-backed-source authored shape: a model's
|
||||
//! `table_reference` with only a `table` key — no `catalog`/`schema` at all — e.g.
|
||||
//!
|
||||
//! ```yaml
|
||||
//! table_reference:
|
||||
//! table: /abs/path/to/file.parquet
|
||||
//! ```
|
||||
//!
|
||||
//! `wren-core-base`'s custom `table_reference` serde module requires all three keys present on
|
||||
//! the authored mapping (its inner `TableReference` struct has no `#[serde(default)]` on
|
||||
//! `catalog`/`schema`, even though they're `Option<String>`), so passing this object through
|
||||
//! unpadded is a hard "missing field `catalog`" deserialize error — surfaced upstream only as the
|
||||
//! generic `mdl_parseable` precondition failure. `project::model_to_json` pads the missing keys
|
||||
//! with JSON `null` before handoff; this test proves a project authored this way still loads.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use warble::ContextLoader;
|
||||
use wren_context_loader::{read_project_dir, MdlContext};
|
||||
|
||||
fn write(dir: &Path, rel: &str, contents: &str) {
|
||||
let path = dir.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// A minimal schema_version-2 (file-backed source) project: one model whose `table_reference`
|
||||
/// carries only `table`, no `catalog`/`schema` — the exact shape `wren generate-mdl` emits for
|
||||
/// duckdb/csv/local_file sources.
|
||||
fn bare_tableref_project(dir: &Path) {
|
||||
write(
|
||||
dir,
|
||||
"wren_project.yml",
|
||||
"schema_version: 2\nname: filedemo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: local_file\n",
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"models/customers/metadata.yml",
|
||||
r#"name: customers
|
||||
table_reference:
|
||||
table: /abs/path/to/customers.parquet
|
||||
primary_key: customer_id
|
||||
columns:
|
||||
- { name: customer_id, type: INTEGER }
|
||||
- { name: email, type: VARCHAR }
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_table_key_table_reference_still_parses() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
bare_tableref_project(tmp.path());
|
||||
|
||||
let sources = read_project_dir(tmp.path())
|
||||
.expect("read project dir")
|
||||
.expect("fixture is a wren project");
|
||||
let ctx = MdlContext::try_from_sources(&sources)
|
||||
.expect("a table_reference with only `table` (no catalog/schema) must still assemble");
|
||||
|
||||
assert!(ctx.is_parseable());
|
||||
let model = ctx
|
||||
.model("customers")
|
||||
.expect("customers model must be present");
|
||||
assert!(model.columns.contains(&"customer_id".to_string()));
|
||||
assert!(model.columns.contains(&"email".to_string()));
|
||||
}
|
||||
|
||||
/// A single-table project (e.g. a one-CSV onboarding) has no relationships, and the wren CLI
|
||||
/// scaffolds a comment-only `relationships.yml` for it. That parses to YAML null, which matches
|
||||
/// neither `RelationshipsDoc` variant (`Bare` sequence / `Keyed` mapping) — before the fix this
|
||||
/// failed the whole project's assembly and surfaced only as the generic `mdl_parseable`
|
||||
/// precondition failure. An empty/null relationships document must be treated as "no
|
||||
/// relationships", not a hard parse error.
|
||||
#[test]
|
||||
fn empty_relationships_yml_is_treated_as_no_relationships() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
bare_tableref_project(tmp.path());
|
||||
// Comment-only relationships.yml — exactly what the CLI writes for a project with no joins.
|
||||
write(
|
||||
tmp.path(),
|
||||
"relationships.yml",
|
||||
"# Relationships between models\n",
|
||||
);
|
||||
|
||||
let sources = read_project_dir(tmp.path())
|
||||
.expect("read project dir")
|
||||
.expect("fixture is a wren project");
|
||||
let ctx = MdlContext::try_from_sources(&sources)
|
||||
.expect("a comment-only/empty relationships.yml must assemble as zero relationships");
|
||||
|
||||
assert!(ctx.is_parseable());
|
||||
assert!(
|
||||
ctx.model("customers").is_some(),
|
||||
"the model must still load when relationships.yml is empty"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Integration tests for the wren CLI (project schema_version 5) authored shape:
|
||||
//! `relationships.yml` as a `relationships:` keyed mapping (the CLI both scaffolds and
|
||||
//! *requires* this form — it silently loads 0 relationships from a bare list), join types
|
||||
//! in SCREAMING_SNAKE_CASE, and per-cube `cubes/<name>/metadata.yml` files instead of a
|
||||
//! root `cubes.yml`. The original bare-list + root-cubes shape is covered by the
|
||||
//! `jaffle_wren` tests; this file guards that both generations of project load identically.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use warble::{Additivity, ContextLoader};
|
||||
use wren_context_loader::{read_project_dir, MdlContext};
|
||||
|
||||
fn write(dir: &Path, rel: &str, contents: &str) {
|
||||
let path = dir.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// A minimal schema_version-5 project: two models, keyed relationships (MANY_TO_ONE),
|
||||
/// and a cube under `cubes/<name>/metadata.yml` — no root `cubes.yml`.
|
||||
fn v5_project(dir: &Path) {
|
||||
write(
|
||||
dir,
|
||||
"wren_project.yml",
|
||||
"schema_version: 5\nname: v5demo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: duckdb\nprofile: v5demo\n",
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"models/customers/metadata.yml",
|
||||
r#"name: customers
|
||||
table_reference: { catalog: demo, schema: main, table: customers }
|
||||
primary_key: id
|
||||
columns:
|
||||
- { name: id, type: INTEGER }
|
||||
- { name: email, type: VARCHAR }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"models/orders/metadata.yml",
|
||||
r#"name: orders
|
||||
table_reference: { catalog: demo, schema: main, table: orders }
|
||||
primary_key: id
|
||||
columns:
|
||||
- { name: id, type: INTEGER }
|
||||
- { name: customer_id, type: INTEGER }
|
||||
- { name: total, type: "DECIMAL(12,2)" }
|
||||
- { name: placed_at, type: TIMESTAMP }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"relationships.yml",
|
||||
r#"relationships:
|
||||
- name: orders_customers
|
||||
models: [orders, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "orders.customer_id = customers.id"
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"cubes/order_metrics/metadata.yml",
|
||||
r#"name: order_metrics
|
||||
base_object: orders
|
||||
measures:
|
||||
- { name: total_value, expression: "SUM(total)", type: DOUBLE }
|
||||
- { name: avg_value, expression: "AVG(total)", type: DOUBLE }
|
||||
dimensions: []
|
||||
time_dimensions:
|
||||
- { name: placed_at, expression: placed_at, type: TIMESTAMP }
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn load(dir: &Path) -> MdlContext {
|
||||
let sources = read_project_dir(dir)
|
||||
.expect("read project dir")
|
||||
.expect("fixture is a wren project");
|
||||
MdlContext::try_from_sources(&sources).expect("fixture assembles into a valid manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_relationships_are_loaded_not_silently_dropped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
v5_project(tmp.path());
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
let lineage = ctx.lineage();
|
||||
assert!(
|
||||
lineage.contains("rel:orders_customers"),
|
||||
"keyed `relationships:` form must load the relationship"
|
||||
);
|
||||
assert!(
|
||||
lineage
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.from == "model:orders" && e.to == "rel:orders_customers"),
|
||||
"relationship must produce lineage edges"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screaming_snake_case_join_type_is_accepted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
v5_project(tmp.path());
|
||||
// try_from_sources already fails on an invalid manifest; loading at all proves
|
||||
// MANY_TO_ONE deserialized into wren-core-base's JoinType.
|
||||
let ctx = load(tmp.path());
|
||||
assert!(ctx.is_parseable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubes_directory_is_loaded_when_no_root_cubes_yml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
v5_project(tmp.path());
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(
|
||||
ctx.can_answer("metric_additive"),
|
||||
"the cube from cubes/<name>/metadata.yml makes additivity answerable"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.metric_additivity("total_value"),
|
||||
Some(Additivity::Additive)
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.metric_additivity("avg_value"),
|
||||
Some(Additivity::NonAdditive)
|
||||
);
|
||||
assert!(ctx.lineage().contains("cube:order_metrics"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_cubes_yml_wins_over_cubes_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
v5_project(tmp.path());
|
||||
// A root mirror that declares a *different* cube name: if both sources were merged,
|
||||
// the project would carry two cubes; precedence means only the root one loads.
|
||||
write(
|
||||
tmp.path(),
|
||||
"cubes.yml",
|
||||
r#"- name: root_metrics
|
||||
base_object: orders
|
||||
measures:
|
||||
- { name: root_total, expression: "SUM(total)", type: DOUBLE }
|
||||
dimensions: []
|
||||
time_dimensions: []
|
||||
"#,
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(ctx.lineage().contains("cube:root_metrics"));
|
||||
assert!(
|
||||
!ctx.lineage().contains("cube:order_metrics"),
|
||||
"cubes/ directory must be ignored when a root cubes.yml exists (no double-loading)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_list_relationships_still_load() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
v5_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"relationships.yml",
|
||||
r#"- name: orders_customers
|
||||
models: [orders, customers]
|
||||
join_type: many_to_one
|
||||
condition: "orders.customer_id = customers.id"
|
||||
"#,
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
assert!(ctx.lineage().contains("rel:orders_customers"));
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Consumer-node lineage: `knowledge/sql/<slug>.md` confirmed queries and `dashboards.yml` specs
|
||||
//! become `query:`/`dashboard:` nodes downstream of the semantic layer, so a metric is no longer a
|
||||
//! leaf — the blast-radius gate can finally say "this change reaches N dashboards". Also covers
|
||||
//! the degradation paths: unparseable consumer SQL falls back to a whole-word scan and is recorded
|
||||
//! in `lineage_diagnostics` (no silent caps), and a project with no consumer files behaves exactly
|
||||
//! as before consumers existed.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use warble::{ContextLoader, Severity};
|
||||
use wren_context_loader::{read_project_dir, MdlContext};
|
||||
|
||||
fn write(dir: &Path, rel: &str, contents: &str) {
|
||||
let path = dir.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// The same minimal v5-shape project as `cli_v5_project.rs`: two models, one relationship, one
|
||||
/// cube — the semantic layer consumers will point at.
|
||||
fn base_project(dir: &Path) {
|
||||
write(
|
||||
dir,
|
||||
"wren_project.yml",
|
||||
"schema_version: 5\nname: consumerdemo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: duckdb\nprofile: consumerdemo\n",
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"models/customers/metadata.yml",
|
||||
r#"name: customers
|
||||
table_reference: { catalog: demo, schema: main, table: customers }
|
||||
primary_key: id
|
||||
columns:
|
||||
- { name: id, type: INTEGER }
|
||||
- { name: country, type: VARCHAR }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"models/orders/metadata.yml",
|
||||
r#"name: orders
|
||||
table_reference: { catalog: demo, schema: main, table: orders }
|
||||
primary_key: id
|
||||
columns:
|
||||
- { name: id, type: INTEGER }
|
||||
- { name: customer_id, type: INTEGER }
|
||||
- { name: total, type: "DECIMAL(12,2)" }
|
||||
- { name: placed_at, type: TIMESTAMP }
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"relationships.yml",
|
||||
r#"relationships:
|
||||
- name: orders_customers
|
||||
models: [orders, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "orders.customer_id = customers.id"
|
||||
"#,
|
||||
);
|
||||
write(
|
||||
dir,
|
||||
"cubes/order_metrics/metadata.yml",
|
||||
r#"name: order_metrics
|
||||
base_object: orders
|
||||
measures:
|
||||
- { name: total_value, expression: "SUM(total)", type: DOUBLE }
|
||||
dimensions: []
|
||||
time_dimensions:
|
||||
- { name: placed_at, expression: placed_at, type: TIMESTAMP }
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn load(dir: &Path) -> MdlContext {
|
||||
let sources = read_project_dir(dir)
|
||||
.expect("read project dir")
|
||||
.expect("fixture is a wren project");
|
||||
MdlContext::try_from_sources(&sources).expect("fixture assembles into a valid manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_consumer_files_means_no_consumer_nodes_and_no_diagnostics() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(ctx.lineage_diagnostics().is_empty());
|
||||
assert!(
|
||||
!ctx.lineage()
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id.starts_with("query:") || n.id.starts_with("dashboard:")),
|
||||
"a project without consumer artifacts must not grow consumer nodes"
|
||||
);
|
||||
// A metric is still a leaf here — the pre-consumer behavior, unchanged.
|
||||
let radius = ctx
|
||||
.lineage()
|
||||
.blast_radius("metric:order_metrics.total_value");
|
||||
assert!(radius.downstream.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_query_over_a_cube_makes_the_metric_reach_the_query() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"knowledge/sql/total-by-day.md",
|
||||
"---\nnl: total order value per day\nsql: SELECT placed_at, total_value FROM order_metrics GROUP BY placed_at\nsource: user\n---\n",
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(ctx.lineage_diagnostics().is_empty(), "clean parse expected");
|
||||
assert!(ctx.lineage().contains("query:total-by-day"));
|
||||
// The query references the cube as a relation → cube edge; it mentions the measure and the
|
||||
// time dimension by name → metric/dim edges.
|
||||
let radius = ctx
|
||||
.lineage()
|
||||
.blast_radius("metric:order_metrics.total_value");
|
||||
assert_eq!(
|
||||
radius.downstream,
|
||||
vec!["query:total-by-day".to_string()],
|
||||
"the declared metric is no longer a leaf"
|
||||
);
|
||||
assert_eq!(
|
||||
radius.severity,
|
||||
Severity::Semantic,
|
||||
"hitting a consumer is a silent number shift for the end user"
|
||||
);
|
||||
assert!(ctx
|
||||
.lineage()
|
||||
.blast_radius("dim:order_metrics.placed_at")
|
||||
.downstream
|
||||
.contains(&"query:total-by-day".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_query_over_models_hangs_off_each_referenced_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"knowledge/sql/orders-per-country.md",
|
||||
"---\nsql: SELECT c.country, COUNT(*) FROM orders o JOIN customers c ON o.customer_id = c.id GROUP BY 1\n---\n",
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
for model in ["model:orders", "model:customers"] {
|
||||
assert!(
|
||||
ctx.lineage()
|
||||
.blast_radius(model)
|
||||
.downstream
|
||||
.contains(&"query:orders-per-country".to_string()),
|
||||
"{model} must reach the confirmed query"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_declared_cube_panel_links_cube_and_measures() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"dashboards.yml",
|
||||
r#"dashboards:
|
||||
- name: exec-weekly
|
||||
panels:
|
||||
- name: totals
|
||||
cube: order_metrics
|
||||
measures: [total_value]
|
||||
- name: by-country
|
||||
sql: SELECT country, COUNT(*) FROM customers GROUP BY country
|
||||
"#,
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(ctx.lineage_diagnostics().is_empty());
|
||||
assert!(ctx.lineage().contains("dashboard:exec-weekly"));
|
||||
assert!(ctx.lineage().is_resolvable());
|
||||
|
||||
// Declared cube panel: metric → dashboard.
|
||||
let radius = ctx
|
||||
.lineage()
|
||||
.blast_radius("metric:order_metrics.total_value");
|
||||
assert_eq!(radius.downstream, vec!["dashboard:exec-weekly".to_string()]);
|
||||
assert_eq!(radius.severity, Severity::Semantic);
|
||||
|
||||
// SQL panel: model → dashboard. And the full chain: the base model reaches the dashboard
|
||||
// through the cube AND directly counts it once (deduplicated edges).
|
||||
assert!(ctx
|
||||
.lineage()
|
||||
.blast_radius("model:customers")
|
||||
.downstream
|
||||
.contains(&"dashboard:exec-weekly".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_naming_a_missing_cube_is_a_dangling_edge_not_a_silent_skip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"dashboards.yml",
|
||||
r#"dashboards:
|
||||
- name: ghost
|
||||
panels:
|
||||
- { name: broken, cube: no_such_cube, measures: [nope] }
|
||||
"#,
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
// A declared reference to a missing target dangles, exactly like a bad cube base_object —
|
||||
// `lineage_resolvable` (and the components gated on it) go loud instead of quietly ignoring.
|
||||
assert!(!ctx.lineage().is_resolvable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_consumer_sql_falls_back_to_whole_word_scan_and_is_recorded() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
// Not SQL sqlparser can parse — but it names `orders` as a whole word.
|
||||
write(
|
||||
tmp.path(),
|
||||
"knowledge/sql/broken.md",
|
||||
"---\nsql: \"SELCT * FRM ((( orders\"\n---\n",
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(
|
||||
ctx.lineage()
|
||||
.blast_radius("model:orders")
|
||||
.downstream
|
||||
.contains(&"query:broken".to_string()),
|
||||
"fallback whole-word scan must still bind the query to `orders`"
|
||||
);
|
||||
let diagnostics = ctx.lineage_diagnostics();
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|d| d.contains("query:broken") && d.contains("whole-word")),
|
||||
"the degradation must be recorded honestly; diagnostics were: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_file_without_sql_is_skipped_with_a_diagnostic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
base_project(tmp.path());
|
||||
write(
|
||||
tmp.path(),
|
||||
"knowledge/sql/note-only.md",
|
||||
"---\nnl: just a note\n---\n",
|
||||
);
|
||||
let ctx = load(tmp.path());
|
||||
|
||||
assert!(!ctx.lineage().contains("query:note-only"));
|
||||
let diagnostics = ctx.lineage_diagnostics();
|
||||
assert!(
|
||||
diagnostics.iter().any(|d| d.contains("note-only")),
|
||||
"the skip must be recorded; diagnostics were: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The real `examples/driftwood-wren` fixtures, end to end: ≥2 confirmed queries + 1 dashboard
|
||||
/// spec, and the acceptance chain — `blast_radius("metric:mrr_metrics.mrr")` reaches both a
|
||||
/// `query:` and a `dashboard:` node at Semantic severity.
|
||||
#[test]
|
||||
fn driftwood_metric_reaches_its_query_and_dashboard() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/driftwood-wren");
|
||||
let ctx = load(&dir);
|
||||
|
||||
assert!(
|
||||
ctx.lineage_diagnostics().is_empty(),
|
||||
"driftwood consumer fixtures parse cleanly"
|
||||
);
|
||||
let radius = ctx.lineage().blast_radius("metric:mrr_metrics.mrr");
|
||||
assert!(
|
||||
radius.downstream.contains(&"query:mrr-trend".to_string()),
|
||||
"downstream was: {:?}",
|
||||
radius.downstream
|
||||
);
|
||||
assert!(
|
||||
radius
|
||||
.downstream
|
||||
.contains(&"dashboard:exec-weekly".to_string()),
|
||||
"downstream was: {:?}",
|
||||
radius.downstream
|
||||
);
|
||||
assert_eq!(radius.severity, Severity::Semantic);
|
||||
|
||||
// The SQL panel binds the dashboard to the models it queries.
|
||||
assert!(ctx
|
||||
.lineage()
|
||||
.blast_radius("model:orders")
|
||||
.downstream
|
||||
.contains(&"dashboard:exec-weekly".to_string()));
|
||||
|
||||
// And the whole graph still resolves (no dangling consumer references in the fixtures).
|
||||
assert!(ctx.lineage().is_resolvable());
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
# driftwood-wren — a deliberately messy semantic-layer project
|
||||
|
||||
Driftwood Outfitters is a synthetic outdoor-gear e-commerce company built as an eval
|
||||
substrate: a schema where cheap and strong LLMs genuinely disagree, and where the semantic
|
||||
layer (MDL + knowledge) measurably closes the gap. Where `jaffle-wren` is the clean
|
||||
minimal example, driftwood is the adversarial one.
|
||||
|
||||
**Narrative:** launched on a self-built legacy platform in 2019, migrated platforms during
|
||||
2023 (dual-write window — migrated orders exist in BOTH systems), launched a subscription
|
||||
product in 2024, sells in the US and Europe (multi-currency), and runs a February-start
|
||||
fiscal year. Every dirty detail is a consequence of that history, not injected noise.
|
||||
|
||||
## The 15 traps
|
||||
|
||||
Each trap maps 1:1 to a golden tag (`eval/golden/driftwood/cases.yaml`) and to a canonical
|
||||
rule in `knowledge/rules/` — that pairing is the point (see "eval design" below).
|
||||
`TRAPS.md` documents each with a validation SQL + the expected value.
|
||||
|
||||
| # | tag | trap |
|
||||
| --- | --- | --- |
|
||||
| T1 | cross-system-union | historical metrics span legacy + new platform |
|
||||
| T2 | dedup-migration | migrated orders exist in both systems |
|
||||
| T3 | unit-cents | `legacy_orders.amt_c` is integer cents |
|
||||
| T4 | same-name-diff-meaning | `payments.amount` (net) vs `orders.order_total` (gross) |
|
||||
| T5 | refund-double-count | refunds exist as a `refunds` row AND a negative payment |
|
||||
| T6 | test-and-deleted | `is_test` / `deleted_at` inflate naive counts |
|
||||
| T7 | enum-drift | multiple spellings per canonical channel/status value |
|
||||
| T8 | semi-additive | MRR / inventory snapshots must not be summed across time |
|
||||
| T9 | fiscal-calendar | fiscal year starts Feb 1 |
|
||||
| T10 | timezone | legacy timestamps are naive America/Los_Angeles local time |
|
||||
| T11 | currency | multi-currency sums need FX; `fx_rates` is weekdays-only |
|
||||
| T12 | sentinel-null | legacy `ship_dt` uses a `1970-01-01` sentinel |
|
||||
| T13 | identity-dedup | same humans in both systems; xref covers only ~87% |
|
||||
| T14 | grain-mismatch | header total ≠ Σ line items on ~8% of orders |
|
||||
| T15 | returns-vs-refunds | merchandise returns ≠ monetary refunds |
|
||||
|
||||
## Generating the data
|
||||
|
||||
The DuckDB is **not** committed (22 MB); the generator is, and it is deterministic:
|
||||
|
||||
```sh
|
||||
cd examples/driftwood-wren
|
||||
uv run generate.py # ~4 min → driftwood.duckdb (18 tables, ~693k rows, seed 42)
|
||||
```
|
||||
|
||||
Same seed → identical query results across runs (stdlib `random.Random(42)` only — no
|
||||
faker, no `datetime.now()`; "today" is pinned to 2026-06-30). File bytes may differ;
|
||||
golden truths depend on query results, which do not.
|
||||
|
||||
### Pinned eval fixture
|
||||
|
||||
The expensive live eval does not regenerate those ~693k rows. A synthetic clean base is published
|
||||
as the dedicated GitHub Release asset pinned by `fixture.lock.json`; `fixture.py` verifies its exact
|
||||
size and SHA-256 and stores it under a content-addressed local cache:
|
||||
|
||||
```sh
|
||||
just driftwood-fixture
|
||||
# or choose an isolated cache/output explicitly
|
||||
python3 fixture.py fetch --cache-dir /tmp/warble-fixtures --output /tmp/driftwood.duckdb
|
||||
```
|
||||
|
||||
A cache hit performs no network request. A missing release asset, bad lock, wrong size, or checksum
|
||||
mismatch fails loudly — it never falls back to generation. The base fixture is synthetic and
|
||||
derived; `generate.py`, `BASE_FIXTURE_VERSION`, and the committed lock remain the authority.
|
||||
|
||||
Refreshing is deliberately explicit:
|
||||
|
||||
1. Change base-generation semantics and bump `BASE_FIXTURE_VERSION` (injection-only/tooling changes
|
||||
do not require a refresh).
|
||||
2. Generate the clean database locally with `uv run generate.py`; verify table count, total rows,
|
||||
and the maximum `subscription_snapshots.snapshot_date`.
|
||||
3. Publish it under a new `eval-fixtures-vN` GitHub Release/tag and asset name.
|
||||
4. Update every identity/checksum/expectation field in `fixture.lock.json` in the same PR, then run
|
||||
`uv run test_fixture.py` and the fixture-backed injection smoke below.
|
||||
|
||||
Do not replace an existing release asset in place: immutable tag + asset + checksum identity makes
|
||||
old commits reproducible and prevents a cache from silently changing underneath them.
|
||||
|
||||
To query it through the `wren` CLI, register a duckdb profile whose `url` is this
|
||||
directory (the project binds `profile: driftwood` in `wren_project.yml`), then
|
||||
`wren context build`.
|
||||
|
||||
## Fault injection
|
||||
|
||||
`generate.py` can also inject a deterministic anomaly into a *second* copy of the same
|
||||
seed-42 dataset, for building/testing `monitor_freshness`-style detectors:
|
||||
|
||||
```sh
|
||||
uv run generate.py --inject stopped_updates # → driftwood-stopped_updates.duckdb + .manifest.yaml
|
||||
uv run generate.py --inject sudden_drop --out /tmp/x.duckdb
|
||||
uv run generate.py --verify # self-check suite; writes nothing
|
||||
```
|
||||
|
||||
To reuse the pinned/generated clean base rather than regenerate it:
|
||||
|
||||
```sh
|
||||
base=$(python3 fixture.py fetch)
|
||||
uv run generate.py --inject stopped_updates --base "$base" --out /tmp/injected.duckdb
|
||||
```
|
||||
|
||||
`--base` copies the database and reads/rewrites only the tables touched by the selected scenario;
|
||||
the source fixture is never modified. For `stopped_updates`, that means only
|
||||
`subscription_snapshots`, so preparation takes seconds instead of rebuilding the full dataset.
|
||||
|
||||
- `--inject <scenario>` generates the clean `driftwood.duckdb` as usual (or accepts an existing
|
||||
clean database through `--base`), then applies one of the four scenarios below and writes it to
|
||||
`driftwood-<scenario>.duckdb` (override with `--out`), alongside a
|
||||
`driftwood-<scenario>.manifest.yaml` (or `<out>` with a `.manifest.yaml` suffix)
|
||||
describing exactly what was mutated.
|
||||
- `--verify` runs the self-check suite (reproducibility, manifest shape, oracle sanity)
|
||||
and exits nonzero on failure.
|
||||
- Scenario logic never touches the shared `random.Random(42)` sequence: mutations use
|
||||
fixed literals, so `--inject` is reproducible by construction, and the clean db's rows
|
||||
are byte-for-byte the same whether or not `--inject` is also passed.
|
||||
|
||||
Both the clean and the injected db are built from the *same* seeded run — the clean db is
|
||||
the false-alarm baseline (a competent detector must **not** flag anything in it); the
|
||||
injected db is the true-positive target. Scoring only the injected db can't measure false
|
||||
alarms — you need the pair.
|
||||
|
||||
### The four scenarios
|
||||
|
||||
| scenario | table.column | mechanism |
|
||||
| --- | --- | --- |
|
||||
| `stopped_updates` | `subscription_snapshots.snapshot_date` | drop every month-end snapshot after 2026-03-31 — the feed silently stopped |
|
||||
| `sudden_drop` | `orders.order_total` | multiply the header total by 0.3 for every order placed in 2026-03 — a level shift, not gradual |
|
||||
| `drift` | `fx_rates.usd_rate` (EUR) | compound the rate upward ~0.15%/day over 2026-01-01..2026-06-30 — a stale/broken FX feed |
|
||||
| `duplicates` | `orders` + `order_items` + `payments` | re-insert orders 1000-1004 (and their items/payments) verbatim under new ids — a retried batch job |
|
||||
|
||||
Only `stopped_updates` produces a genuine freshness verdict (`expected_fresh` /
|
||||
`expected_severity`), computed with the exact heuristic `monitor_freshness` uses
|
||||
(`hub/components/monitor_freshness/steps/assess_severity.md`): `fresh = lag_hours <=
|
||||
cadence_hours`; otherwise `warn` if `lag_hours <= 2 * cadence_hours` else `critical`
|
||||
(assumed cadence: 730h, a month-end snapshot table). The other three scenarios are
|
||||
magnitude anomalies, not freshness ones — their `expected_severity` is a self-authored,
|
||||
documented-in-`generate.py` heuristic (or `null` for `duplicates`, a content anomaly with
|
||||
no natural magnitude axis).
|
||||
|
||||
### Manifest schema
|
||||
|
||||
`seed, base, injected, reference_now, dataset, scenario, injections[]`, where each
|
||||
injection has `id, kind, entity, location{table,column,row_id_range,timestamp_cutoff,
|
||||
date_window}, magnitude{...}, expected_verdict, expected_fresh, expected_severity,
|
||||
expected_cause, attribution_keywords[]`, plus extra fields for scorer-checkability
|
||||
(`affected_row_ids`, `new_row_ids`, `pre_mutation_summary`, `post_mutation_summary`). A scenario's `injections`
|
||||
list usually has one entry, but can have more — `duplicates` emits three (one per touched
|
||||
table), since it genuinely spans more than one entity.
|
||||
|
||||
### The oracle
|
||||
|
||||
`score_detections(manifest, detections)` scores a hand-authored *detection set* — a list
|
||||
of `{entity, verdict, cause}` dicts covering both the injected entities and some clean ones
|
||||
— against a manifest, producing four metrics:
|
||||
|
||||
- **recall** — fraction of real injections the detector flagged. Counted **per injection**,
|
||||
not per scenario: `duplicates` emits three injections (`orders`, `order_items`,
|
||||
`payments`) for what is conceptually one event, so a detector that flags only `orders`
|
||||
scores 1/3 recall, not 1/1 — intentional, it rewards flagging every affected entity.
|
||||
- **precision** — fraction of the detector's `anomaly` claims that were real. Counted **per
|
||||
detection** (unaffected by how many injections one scenario emits).
|
||||
- **false_alarm_rate** — the headline metric: fraction of *clean* entities the detector
|
||||
wrongly flagged, also per detection. This is why the clean/injected pair matters — false
|
||||
alarms aren't measurable from the injected db alone.
|
||||
- **attribution_accuracy** — of the true positives, the fraction whose stated `cause`
|
||||
keyword-overlaps (≥50%) the short canonical `attribution_keywords` set serialized on each
|
||||
injection (the phenomenon verb + the table/column name), not
|
||||
the full `expected_cause` sentence (which embeds ids/dates a paraphrasing detector won't
|
||||
quote). A detector's own words only need to name the same table/column/phenomenon — it
|
||||
never has to reproduce the manifest text. `--verify` checks all four scenarios: each
|
||||
injection's own `expected_cause` self-matches at 1.0, a realistic hand-written paraphrase
|
||||
scores ≥ 0.5, and an unrelated cause scores < 0.5.
|
||||
|
||||
`score_detections` assumes at most one detection per entity in `detections` (raises
|
||||
`ValueError` otherwise) — with two detections for the same entity, precision and
|
||||
false_alarm_rate could otherwise exceed 1.0.
|
||||
|
||||
`--verify` demonstrates this on hand-authored perfect (recall = precision = 1.0,
|
||||
false_alarm_rate = 0.0) and imperfect (misses one injection, false-alarms on one clean
|
||||
entity) detection sets for `stopped_updates`, asserting the hand-computed expected numbers.
|
||||
|
||||
## Eval design: MDL vs knowledge is the experiment axis
|
||||
|
||||
- **MDL column descriptions carry only *local* facts** — units (cents), timezones,
|
||||
sentinels, net-vs-gross, snapshot grain. Regenerate with `uv run scaffold_models.py`
|
||||
(descriptions are embedded there and cross-checked against `schema_dump.csv`).
|
||||
- **Global business rules live only in `knowledge/rules/`** — canonical revenue formula,
|
||||
cross-system dedup, test-account exclusion, UTC reporting timezone, fiscal calendar,
|
||||
units-sold definition.
|
||||
|
||||
The `schema-only` control injects the compiled schema without `knowledge/rules/`; the
|
||||
`schema+knowledge` treatment also injects those rules from this Wren project. The suite now
|
||||
contains 53 goldens; the historical 43-case measurement was run with `warble eval run`
|
||||
(`answer_query` via headless Claude Code):
|
||||
|
||||
| accuracy (cost) | schema-only | schema+knowledge |
|
||||
| --- | --- | --- |
|
||||
| **haiku (cheap)** | 0.23 ($1.60) | **0.60** ($2.38) |
|
||||
| **sonnet (strong)** | 0.44 ($8.36) | **0.93** ($7.10) |
|
||||
|
||||
A cheap model with knowledge beats a strong model without it — and unlike on jaffle
|
||||
(where every tier scores 100%), the tier gap here is real, so the eval loop produces a
|
||||
non-trivial tier decision.
|
||||
|
||||
**Golden discipline learned the hard way:** golden truths must apply the project's own
|
||||
knowledge rules (the v1 goldens didn't; the stronger model followed the rules and was
|
||||
marked wrong). Any new golden's truth SQL must honor every canonical rule, and top-N
|
||||
goldens must be checked for rank ties.
|
||||
|
||||
## Project shape (wren CLI v5)
|
||||
|
||||
This project is authored in the current wren CLI project shape — keyed `relationships:`
|
||||
mapping and per-cube `cubes/<name>/metadata.yml` (with a root `cubes.yml` mirror) — so it
|
||||
also serves as the in-repo integration fixture for that adapter path (`jaffle-wren`
|
||||
covers the older bare-list shape).
|
||||
@@ -0,0 +1,357 @@
|
||||
# Driftwood trap catalogue (T1-T15)
|
||||
|
||||
Each trap below is: a short description of the mess and why it exists in the
|
||||
Driftwood Outfitters narrative, one validation SQL query (runnable via
|
||||
`duckdb driftwood.duckdb`), and the actual value(s) computed against the
|
||||
generated database (seed 42). Numbers were captured by running
|
||||
`_validate.sql`-equivalent queries against `driftwood.duckdb` after
|
||||
`uv run generate.py`; re-running the generator reproduces them exactly
|
||||
(see README.md "Determinism").
|
||||
|
||||
---
|
||||
|
||||
### T1 — `cross-system-union`
|
||||
|
||||
Revenue and order volume span two systems: the legacy platform (2019-01 to
|
||||
2023-08) and the new platform (2023-03 onward). A query that only touches
|
||||
`orders` silently drops every order placed before the migration window
|
||||
started (2019-01 to 2023-02) and undercounts historical volume.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders) AS new_platform_orders_only,
|
||||
(SELECT COUNT(*) FROM orders)
|
||||
+ (SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NULL) AS deduped_union,
|
||||
(SELECT COUNT(*) FROM orders o JOIN customers c ON c.id = o.customer_id
|
||||
WHERE NOT c.is_test AND o.legacy_ord_id IS NULL)
|
||||
+ (SELECT COUNT(*) FROM legacy_orders) AS canonical_all_time_orders;
|
||||
```
|
||||
|
||||
**Result:** `new_platform_orders_only = 60,000` vs `deduped_union =
|
||||
102,174` — the naive query misses ~42% of all-time order volume. The
|
||||
**canonical** answer additionally applies the knowledge rules (test-account
|
||||
exclusion on every business metric): `canonical_all_time_orders = 100,994`,
|
||||
which is what golden `g03_total_orders_all_time` expects.
|
||||
|
||||
---
|
||||
|
||||
### T2 — `dedup-migration`
|
||||
|
||||
Legacy orders placed during the 2023-03→2023-08 migration window with
|
||||
status paid/shipped/delivered were dual-written: the original row stays in
|
||||
`legacy_orders` (flagged via `migrated_at`) and a duplicate is inserted into
|
||||
`orders` (flagged via `legacy_ord_id`). A naive `UNION` of the two tables
|
||||
double-counts every migrated order.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders WHERE legacy_ord_id IS NOT NULL) AS migrated_dupes_in_orders,
|
||||
(SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NOT NULL) AS migrated_flagged_in_legacy;
|
||||
```
|
||||
|
||||
**Result:** `2,826` = `2,826` — the two flags agree exactly, confirming
|
||||
the fix (`WHERE legacy_ord_id IS NULL` or `WHERE migrated_at IS NULL`) is
|
||||
correct.
|
||||
|
||||
---
|
||||
|
||||
### T3 — `unit-cents`
|
||||
|
||||
`legacy_orders.amt_c` is an integer in **cents**, USD only. Summing it
|
||||
directly and treating the result as dollars overstates revenue by 100x.
|
||||
|
||||
```sql
|
||||
SELECT SUM(amt_c) AS naive_sum_treated_as_dollars, SUM(amt_c) / 100.0 AS correct_sum_usd
|
||||
FROM legacy_orders;
|
||||
```
|
||||
|
||||
**Result:** `1,437,820,899` (naive) vs `14,378,208.99` (correct) — exactly
|
||||
100x apart.
|
||||
|
||||
---
|
||||
|
||||
### T4 — `same-name-diff-meaning`
|
||||
|
||||
`payments.amount` is the **net** amount captured (gross minus processor
|
||||
fee); `orders.order_total` is the **gross** order value. They share no
|
||||
column name collision but are frequently confused because both represent
|
||||
"how much this order is worth." Recognized revenue must reconstruct gross
|
||||
as `amount + fee_amount`.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
SUM(p.amount) AS naive_net_sum,
|
||||
SUM(p.amount + p.fee_amount) AS gross_reconstructed_from_payments,
|
||||
(SELECT SUM(o.order_total) FROM orders o WHERE o.id IN (SELECT order_id FROM payments WHERE amount > 0))
|
||||
AS order_total_sum_for_paid_orders
|
||||
FROM payments p WHERE p.method != 'refund_reversal';
|
||||
```
|
||||
|
||||
**Result:** `naive_net_sum = 15,709,036.14` vs
|
||||
`gross_reconstructed_from_payments = 16,193,909.41`, which matches
|
||||
`order_total_sum_for_paid_orders = 16,193,909.41` exactly — using the net
|
||||
figure alone understates recognized revenue by ~$484,873 (fees).
|
||||
|
||||
---
|
||||
|
||||
### T5 — `refund-double-count`
|
||||
|
||||
Every refund produces **two** artifacts: a positive-gross row in `refunds`
|
||||
*and* a negative-amount row in `payments` (`method = 'refund_reversal'`).
|
||||
Subtracting both from revenue double-counts the refund.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM payments WHERE amount < 0) AS negative_payment_rows,
|
||||
(SELECT COUNT(*) FROM refunds) AS refund_rows,
|
||||
(SELECT SUM(-amount) FROM payments WHERE amount < 0) AS negative_payment_total,
|
||||
(SELECT SUM(amount) FROM refunds) AS refund_total;
|
||||
```
|
||||
|
||||
**Result:** `negative_payment_rows = 3,845` = `refund_rows = 3,845`;
|
||||
`negative_payment_total = 892,028.20` = `refund_total = 892,028.20` — the
|
||||
two representations are exact duplicates of each other, by construction.
|
||||
|
||||
---
|
||||
|
||||
### T6 — `test-and-deleted`
|
||||
|
||||
`customers.is_test` (internal QA accounts) and `deleted_at` (soft deletes)
|
||||
both inflate a naive `COUNT(*)` on `customers`.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM customers) AS all_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_test = false AND deleted_at IS NULL) AS clean_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_test = true) AS test_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE deleted_at IS NOT NULL) AS deleted_customers;
|
||||
```
|
||||
|
||||
**Result:** `all_customers = 8,000` vs `clean_customers = 7,508`
|
||||
(`test_customers = 164`, `deleted_customers = 332`) — the naive count is
|
||||
inflated by ~6.6%.
|
||||
|
||||
---
|
||||
|
||||
### T7 — `enum-drift`
|
||||
|
||||
`customers.marketing_channel` has ten raw values that collapse into four
|
||||
canonical buckets (Facebook: `fb`/`facebook`/`Facebook Ads`; Google:
|
||||
`google`/`adwords`/`Google`; Email: `email`/`newsletter`; Organic; NULL). A
|
||||
naive `GROUP BY` fragments what should be one bucket into three or more
|
||||
rows. Independently, `orders.status` has `'paid'` spelled three ways.
|
||||
|
||||
```sql
|
||||
SELECT marketing_channel, COUNT(*) AS n FROM customers GROUP BY marketing_channel ORDER BY n DESC;
|
||||
|
||||
SELECT status, COUNT(*) AS n FROM orders WHERE lower(status) = 'paid' GROUP BY status ORDER BY n DESC;
|
||||
```
|
||||
|
||||
**Result (channel):** `NULL=1,574`, `organic=1,572`, `google=958`,
|
||||
`fb=834`, `email=784`, `facebook=616`, `adwords=514`,
|
||||
`Facebook Ads=426`, `newsletter=386`, `Google=336` — canonical Facebook
|
||||
total = 834+616+426 = **1,876**; canonical Google total =
|
||||
958+514+336 = **1,808**; canonical Email total = 784+386 = **1,170**, none
|
||||
of which is visible without the canonical mapping.
|
||||
|
||||
**Result (status):** `paid=18,650`, `PAID=275`, `Paid=259` — the case
|
||||
variants are 534/19,184 ≈ **2.8%** of all paid-family rows (target ~3%).
|
||||
|
||||
---
|
||||
|
||||
### T8 — `semi-additive`
|
||||
|
||||
`subscription_snapshots.mrr_amount` and `inventory_levels.units_on_hand`
|
||||
are month-end **snapshots**. Both are semi-additive: they must never be
|
||||
summed across time (only across the entity dimension, at a fixed month).
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT SUM(mrr_amount) FROM subscription_snapshots) AS naive_sum_all_months_wrong,
|
||||
(SELECT SUM(mrr_amount) FROM subscription_snapshots
|
||||
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM subscription_snapshots)) AS correct_latest_month_mrr;
|
||||
|
||||
SELECT
|
||||
(SELECT SUM(units_on_hand) FROM inventory_levels) AS naive_sum_all_snapshots_wrong,
|
||||
(SELECT SUM(units_on_hand) FROM inventory_levels
|
||||
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM inventory_levels)) AS correct_latest_snapshot_total;
|
||||
```
|
||||
|
||||
**Result (MRR):** naive sum across 30 month-end snapshots =
|
||||
`560,005.51` vs correct current MRR = `28,687.92` — off by ~19.5x.
|
||||
|
||||
**Result (inventory):** naive sum across all monthly snapshots =
|
||||
`5,981,264` vs correct current on-hand = `150,807` — off by ~40x.
|
||||
|
||||
---
|
||||
|
||||
### T9 — `fiscal-calendar`
|
||||
|
||||
Driftwood's fiscal year starts **February 1**. `fiscal_calendar` maps every
|
||||
calendar date to `(fiscal_year, fiscal_quarter)`; a query using calendar
|
||||
quarters for "Q3 revenue" answers the wrong question.
|
||||
|
||||
```sql
|
||||
SELECT date, fiscal_year, fiscal_quarter FROM fiscal_calendar
|
||||
WHERE date IN ('2024-01-31','2024-02-01','2025-01-31','2025-02-01') ORDER BY date;
|
||||
```
|
||||
|
||||
**Result:**
|
||||
|
||||
| date | fiscal_year | fiscal_quarter |
|
||||
| --- | --- | --- |
|
||||
| 2024-01-31 | 2023 | FY2023-Q4 |
|
||||
| 2024-02-01 | 2024 | FY2024-Q1 |
|
||||
| 2025-01-31 | 2024 | FY2024-Q4 |
|
||||
| 2025-02-01 | 2025 | FY2025-Q1 |
|
||||
|
||||
A single calendar-day boundary (Jan 31 → Feb 1) flips both the fiscal year
|
||||
and the quarter.
|
||||
|
||||
---
|
||||
|
||||
### T10 — `timezone`
|
||||
|
||||
`legacy_orders.ord_dt` is a **naive** local-time string in
|
||||
`America/Los_Angeles`, generated from a true UTC instant. Treating the
|
||||
string as if it were already UTC shifts the calendar day for any order
|
||||
placed near midnight LA time.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_legacy_orders,
|
||||
COUNT(*) FILTER (
|
||||
-- timezone('UTC', timezone('America/Los_Angeles', ts)) = interpret the naive string
|
||||
-- as LA local, then express it as naive UTC — independent of the session timezone
|
||||
-- (date_trunc on a TIMESTAMPTZ would silently truncate in the session zone instead).
|
||||
WHERE strftime(timezone('UTC', timezone('America/Los_Angeles', ord_dt::TIMESTAMP)), '%Y-%m-%d')
|
||||
!= substr(ord_dt, 1, 10)
|
||||
) AS rows_where_naive_utc_reading_shifts_the_calendar_day
|
||||
FROM legacy_orders;
|
||||
```
|
||||
|
||||
**Result:** `13,736` of `45,000` rows (~30.5%) land on a different UTC
|
||||
calendar day than their naive local date — consistent with a 7-8 hour
|
||||
offset (≈ 7.5/24 of uniformly-drawn timestamps sit within the offset of a
|
||||
day boundary). Any day/month/year bucketing that misreads the naive string
|
||||
as UTC misplaces those rows.
|
||||
|
||||
---
|
||||
|
||||
### T11 — `currency`
|
||||
|
||||
`orders.currency` is USD/EUR/GBP. Summing `order_total` across currencies
|
||||
without conversion produces a meaningless number. `fx_rates` only has
|
||||
**weekday** rows (no Sat/Sun), so a same-day join needs a last-value
|
||||
carry-forward for weekend-captured payments.
|
||||
|
||||
```sql
|
||||
SELECT currency, COUNT(*) AS n, SUM(order_total) AS naive_meaningless_sum FROM orders GROUP BY currency ORDER BY currency;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM fx_rates WHERE currency='EUR') AS eur_days_with_rate,
|
||||
(SELECT COUNT(*) FROM generate_series(DATE '2023-01-01', DATE '2026-06-30', INTERVAL 1 DAY)) AS total_calendar_days_in_range;
|
||||
```
|
||||
|
||||
**Result:** `EUR: n=14,417, sum=4,576,032.18` / `GBP: n=5,680,
|
||||
sum=1,751,345.71` / `USD: n=39,903, sum=12,774,201.62` — adding these three
|
||||
sums together is not a valid USD total. `fx_rates` covers `912` of `1,277`
|
||||
calendar days (~71.4%, i.e. exactly the weekday fraction) — any date-join
|
||||
must fall back to the most recent prior rate on weekends.
|
||||
|
||||
---
|
||||
|
||||
### T12 — `sentinel-null`
|
||||
|
||||
`legacy_orders.ship_dt` uses the epoch-zero sentinel
|
||||
`'1970-01-01 00:00:00'` to mean "never shipped," instead of `NULL`. Treated
|
||||
as a real date, it silently becomes the oldest "ship date" in the dataset.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ship_dt = '1970-01-01 00:00:00') AS sentinel_unshipped_rows,
|
||||
COUNT(*) FILTER (WHERE ship_dt != '1970-01-01 00:00:00') AS real_ship_dt_rows,
|
||||
COUNT(*) AS total
|
||||
FROM legacy_orders;
|
||||
```
|
||||
|
||||
**Result:** `sentinel_unshipped_rows = 19,333` / `real_ship_dt_rows =
|
||||
25,667` out of `45,000` — 43% of legacy orders would appear to have
|
||||
shipped on 1970-01-01 unless the sentinel is filtered out.
|
||||
|
||||
---
|
||||
|
||||
### T13 — `identity-dedup`
|
||||
|
||||
~3,000 humans exist in both `customers` and `legacy_customers` under
|
||||
casing/whitespace email variants (e.g. `Jane.Doe@gmail.com` vs
|
||||
`jane.doe@gmail.com `). `customer_xref` links only ~87% of them explicitly;
|
||||
the rest are only resolvable by normalizing and joining on email.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM legacy_customers lc JOIN customers c
|
||||
ON lower(trim(lc.email)) = lower(trim(c.email))) AS true_overlap_by_email,
|
||||
(SELECT COUNT(*) FROM customer_xref) AS xref_rows,
|
||||
(SELECT COUNT(*) FROM legacy_customers l
|
||||
WHERE EXISTS (SELECT 1 FROM customers c
|
||||
WHERE lower(trim(c.email)) = lower(trim(l.email)))
|
||||
AND NOT EXISTS (SELECT 1 FROM customer_xref x
|
||||
WHERE x.cust_ref = l.cust_ref)) AS overlap_missed_by_xref;
|
||||
```
|
||||
|
||||
**Result:** `true_overlap_by_email = 2,936` vs `xref_rows = 2,550`, and
|
||||
`overlap_missed_by_xref = 442` (golden `g38_xref_gap`). Note the gap is
|
||||
**not** `2,936 − 2,550 = 386`: the two sets only partially overlap — 56 of
|
||||
the 2,550 xref rows map legacy customers whose emails do *not* normalize to
|
||||
a new-platform match, so a set difference, not a count difference, is
|
||||
required. A naive customer count that trusts `customer_xref` alone misses
|
||||
442 real overlaps (~15%) that only surface via `lower(trim(email))`
|
||||
matching.
|
||||
|
||||
---
|
||||
|
||||
### T14 — `grain-mismatch`
|
||||
|
||||
`orders.order_total` (header grain) and `SUM(order_items)` (line grain)
|
||||
mostly agree exactly, but ~8% of orders carry a small rounding delta
|
||||
(±$0.01-$0.99) between the two representations.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) AS total_orders,
|
||||
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) <= 0.005) AS exact_match,
|
||||
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) AS mismatched,
|
||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) / COUNT(*), 2) AS pct_mismatched
|
||||
FROM orders o
|
||||
JOIN (SELECT order_id, SUM(quantity*unit_price - discount_amount) AS items_sum FROM order_items GROUP BY order_id) i
|
||||
ON i.order_id = o.id;
|
||||
```
|
||||
|
||||
**Result:** `total_orders = 60,000`, `exact_match = 55,172`,
|
||||
`mismatched = 4,828` → **8.05%** mismatched (target ~8%) — a reconciliation
|
||||
report keyed on `order_total` alone will disagree with one built from
|
||||
`order_items` for roughly 1 in 12 orders.
|
||||
|
||||
---
|
||||
|
||||
### T15 — `returns-vs-refunds`
|
||||
|
||||
`returns` (RMA / physical product returns) and `refunds` (monetary refunds)
|
||||
are independent concepts with only partial overlap — a returned item
|
||||
doesn't always trigger a refund, and a refund doesn't require a physical
|
||||
return. Using one table to answer a question about the other undercounts
|
||||
or overcounts depending on direction.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id) AS orders_with_returns,
|
||||
(SELECT COUNT(DISTINCT order_id) FROM refunds) AS orders_with_refunds,
|
||||
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id
|
||||
WHERE oi.order_id IN (SELECT order_id FROM refunds)) AS orders_with_both;
|
||||
```
|
||||
|
||||
**Result:** `orders_with_returns = 1,977`, `orders_with_refunds = 3,845`,
|
||||
`orders_with_both = 142` — only ~4% of returns orders also show up in
|
||||
refunds (and vice versa), confirming the two are mostly disjoint
|
||||
populations that must not be conflated.
|
||||
@@ -0,0 +1,111 @@
|
||||
.mode line
|
||||
.print '=== T1 cross-system-union ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders) AS new_platform_orders_only,
|
||||
(SELECT COUNT(*) FROM orders) + (SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NULL) AS correct_historical_order_count;
|
||||
|
||||
.print '=== T2 dedup-migration ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders WHERE legacy_ord_id IS NOT NULL) AS migrated_dupes_in_orders,
|
||||
(SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NOT NULL) AS migrated_flagged_in_legacy;
|
||||
|
||||
.print '=== T3 unit-cents ==='
|
||||
SELECT
|
||||
SUM(amt_c) AS naive_sum_treated_as_dollars,
|
||||
SUM(amt_c) / 100.0 AS correct_sum_usd
|
||||
FROM legacy_orders;
|
||||
|
||||
.print '=== T4 same-name-diff-meaning ==='
|
||||
SELECT
|
||||
SUM(p.amount) AS naive_net_sum,
|
||||
SUM(p.amount + p.fee_amount) AS gross_reconstructed_from_payments,
|
||||
(SELECT SUM(o.order_total) FROM orders o WHERE o.id IN (SELECT order_id FROM payments WHERE amount > 0)) AS order_total_sum_for_paid_orders
|
||||
FROM payments p WHERE p.method != 'refund_reversal';
|
||||
|
||||
.print '=== T5 refund-double-count ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM payments WHERE amount < 0) AS negative_payment_rows,
|
||||
(SELECT COUNT(*) FROM refunds) AS refund_rows,
|
||||
(SELECT SUM(-amount) FROM payments WHERE amount < 0) AS negative_payment_total,
|
||||
(SELECT SUM(amount) FROM refunds) AS refund_total;
|
||||
|
||||
.print '=== T6 test-and-deleted ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM customers) AS all_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_test = false AND deleted_at IS NULL) AS clean_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE is_test = true) AS test_customers,
|
||||
(SELECT COUNT(*) FROM customers WHERE deleted_at IS NOT NULL) AS deleted_customers;
|
||||
|
||||
.print '=== T7 enum-drift (marketing_channel) ==='
|
||||
SELECT marketing_channel, COUNT(*) AS n FROM customers GROUP BY marketing_channel ORDER BY n DESC;
|
||||
|
||||
.print '=== T7 enum-drift (order status paid family) ==='
|
||||
SELECT status, COUNT(*) AS n FROM orders WHERE lower(status) = 'paid' GROUP BY status ORDER BY n DESC;
|
||||
|
||||
.print '=== T8 semi-additive (subscriptions MRR) ==='
|
||||
SELECT
|
||||
(SELECT SUM(mrr_amount) FROM subscription_snapshots) AS naive_sum_all_months_wrong,
|
||||
(SELECT SUM(mrr_amount) FROM subscription_snapshots WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM subscription_snapshots)) AS correct_latest_month_mrr,
|
||||
(SELECT COUNT(DISTINCT snapshot_date) FROM subscription_snapshots) AS n_months;
|
||||
|
||||
.print '=== T8 semi-additive (inventory) ==='
|
||||
SELECT
|
||||
(SELECT SUM(units_on_hand) FROM inventory_levels) AS naive_sum_all_snapshots_wrong,
|
||||
(SELECT SUM(units_on_hand) FROM inventory_levels WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM inventory_levels)) AS correct_latest_snapshot_total;
|
||||
|
||||
.print '=== T9 fiscal-calendar ==='
|
||||
SELECT date, fiscal_year, fiscal_quarter FROM fiscal_calendar WHERE date IN ('2024-01-31','2024-02-01','2025-01-31','2025-02-01') ORDER BY date;
|
||||
|
||||
.print '=== T10 timezone ==='
|
||||
SELECT
|
||||
COUNT(*) AS total_legacy_orders,
|
||||
COUNT(*) FILTER (
|
||||
-- naive LA local -> naive UTC, independent of the session timezone
|
||||
WHERE strftime(timezone('UTC', timezone('America/Los_Angeles', ord_dt::TIMESTAMP)), '%Y-%m-%d')
|
||||
!= substr(ord_dt, 1, 10)
|
||||
) AS rows_where_naive_utc_reading_shifts_the_calendar_day
|
||||
FROM legacy_orders;
|
||||
|
||||
.print '=== T11 currency ==='
|
||||
SELECT currency, COUNT(*) AS n, SUM(order_total) AS naive_meaningless_sum FROM orders GROUP BY currency ORDER BY currency;
|
||||
|
||||
.print '=== T11 currency (fx coverage / weekend gap) ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM fx_rates WHERE currency='EUR') AS eur_days_with_rate,
|
||||
(SELECT COUNT(*) FROM generate_series(DATE '2023-01-01', DATE '2026-06-30', INTERVAL 1 DAY)) AS total_calendar_days_in_range;
|
||||
|
||||
.print '=== T12 sentinel-null ==='
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ship_dt = '1970-01-01 00:00:00') AS sentinel_unshipped_rows,
|
||||
COUNT(*) FILTER (WHERE ship_dt != '1970-01-01 00:00:00') AS real_ship_dt_rows,
|
||||
COUNT(*) AS total
|
||||
FROM legacy_orders;
|
||||
|
||||
.print '=== T13 identity-dedup ==='
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM legacy_customers lc JOIN customers c
|
||||
ON lower(trim(lc.email)) = lower(trim(c.email))) AS true_overlap_by_email,
|
||||
(SELECT COUNT(*) FROM customer_xref) AS xref_rows,
|
||||
(SELECT COUNT(DISTINCT c.id)
|
||||
FROM customers c
|
||||
LEFT JOIN customer_xref x ON x.customer_id = c.id
|
||||
WHERE x.customer_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM legacy_customers lc WHERE lower(trim(lc.email)) = lower(trim(c.email)))
|
||||
) AS overlap_customers_missed_by_xref_alone;
|
||||
|
||||
.print '=== T14 grain-mismatch ==='
|
||||
SELECT
|
||||
COUNT(*) AS total_orders,
|
||||
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) <= 0.005) AS exact_match,
|
||||
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) AS mismatched,
|
||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) / COUNT(*), 2) AS pct_mismatched
|
||||
FROM orders o
|
||||
JOIN (SELECT order_id, SUM(quantity*unit_price - discount_amount) AS items_sum FROM order_items GROUP BY order_id) i
|
||||
ON i.order_id = o.id;
|
||||
|
||||
.print '=== T15 returns-vs-refunds ==='
|
||||
SELECT
|
||||
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id) AS orders_with_returns,
|
||||
(SELECT COUNT(DISTINCT order_id) FROM refunds) AS orders_with_refunds,
|
||||
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id
|
||||
WHERE oi.order_id IN (SELECT order_id FROM refunds)) AS orders_with_both;
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mirror of cubes/<name>/metadata.yml for consumers that read a root cubes.yml
|
||||
# (warble bindings/mdl-context). Keep in sync with the cubes/ directory.
|
||||
- name: mrr_metrics
|
||||
base_object: subscription_snapshots
|
||||
measures:
|
||||
# Semi-additive: additive across subscriptions AT one snapshot_date, never across dates.
|
||||
- name: mrr
|
||||
expression: SUM(mrr_amount)
|
||||
type: DOUBLE
|
||||
- name: active_subscriptions
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
dimensions:
|
||||
- name: status
|
||||
expression: status
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: snapshot_date
|
||||
expression: snapshot_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Point-in-time MRR from month-end snapshots. ALWAYS pin one snapshot_date (or take an average of month-end totals); summing across snapshot dates is meaningless."
|
||||
- name: order_metrics
|
||||
base_object: orders
|
||||
measures:
|
||||
- name: order_count
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
# Additive across orders; header-grain (see knowledge: line-item grain is canonical for merchandise value).
|
||||
- name: total_order_value
|
||||
expression: SUM(order_total)
|
||||
type: DOUBLE
|
||||
# Non-additive: an average of parts is not the average of the whole.
|
||||
- name: avg_order_value
|
||||
expression: AVG(order_total)
|
||||
type: DOUBLE
|
||||
dimensions:
|
||||
- name: status
|
||||
expression: status
|
||||
type: VARCHAR
|
||||
- name: currency
|
||||
expression: currency
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: placed_at
|
||||
expression: placed_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "New-platform order counts and header-grain value in order currency. Includes migrated legacy copies (legacy_ord_id NOT NULL) — dedup per cross-system rules for all-time counts."
|
||||
- name: payment_metrics
|
||||
base_object: payments
|
||||
measures:
|
||||
# Additive: gross captured money movement (excl. refund reversals — filter in queries).
|
||||
- name: gross_captured
|
||||
expression: SUM(amount + fee_amount)
|
||||
type: DOUBLE
|
||||
- name: net_captured
|
||||
expression: SUM(amount)
|
||||
type: DOUBLE
|
||||
- name: payment_count
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
dimensions:
|
||||
- name: method
|
||||
expression: method
|
||||
type: VARCHAR
|
||||
- name: currency
|
||||
expression: currency
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: captured_at
|
||||
expression: captured_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Captured payment volume in transaction currency. gross = amount + fee_amount; excludes nothing by itself — refund reversals (method='refund_reversal') must be filtered out for revenue, and non-USD amounts converted via fx_rates."
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
name: mrr_metrics
|
||||
base_object: subscription_snapshots
|
||||
measures:
|
||||
# Semi-additive: additive across subscriptions AT one snapshot_date, never across dates.
|
||||
- name: mrr
|
||||
expression: SUM(mrr_amount)
|
||||
type: DOUBLE
|
||||
- name: active_subscriptions
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
dimensions:
|
||||
- name: status
|
||||
expression: status
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: snapshot_date
|
||||
expression: snapshot_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Point-in-time MRR from month-end snapshots. ALWAYS pin one snapshot_date (or take an average of month-end totals); summing across snapshot dates is meaningless."
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
name: order_metrics
|
||||
base_object: orders
|
||||
measures:
|
||||
- name: order_count
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
# Additive across orders; header-grain (see knowledge: line-item grain is canonical for merchandise value).
|
||||
- name: total_order_value
|
||||
expression: SUM(order_total)
|
||||
type: DOUBLE
|
||||
# Non-additive: an average of parts is not the average of the whole.
|
||||
- name: avg_order_value
|
||||
expression: AVG(order_total)
|
||||
type: DOUBLE
|
||||
dimensions:
|
||||
- name: status
|
||||
expression: status
|
||||
type: VARCHAR
|
||||
- name: currency
|
||||
expression: currency
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: placed_at
|
||||
expression: placed_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "New-platform order counts and header-grain value in order currency. Includes migrated legacy copies (legacy_ord_id NOT NULL) — dedup per cross-system rules for all-time counts."
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
name: payment_metrics
|
||||
base_object: payments
|
||||
measures:
|
||||
# Additive: gross captured money movement (excl. refund reversals — filter in queries).
|
||||
- name: gross_captured
|
||||
expression: SUM(amount + fee_amount)
|
||||
type: DOUBLE
|
||||
- name: net_captured
|
||||
expression: SUM(amount)
|
||||
type: DOUBLE
|
||||
- name: payment_count
|
||||
expression: COUNT(*)
|
||||
type: BIGINT
|
||||
dimensions:
|
||||
- name: method
|
||||
expression: method
|
||||
type: VARCHAR
|
||||
- name: currency
|
||||
expression: currency
|
||||
type: VARCHAR
|
||||
time_dimensions:
|
||||
- name: captured_at
|
||||
expression: captured_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Captured payment volume in transaction currency. gross = amount + fee_amount; excludes nothing by itself — refund reversals (method='refund_reversal') must be filtered out for revenue, and non-USD amounts converted via fx_rates."
|
||||
@@ -0,0 +1,16 @@
|
||||
# Declarative dashboard specs — the git-native consumer convention Warble's lineage reads
|
||||
# (`docs/spec/blast-radius.md` §3). Each dashboard becomes a `dashboard:<name>` lineage node;
|
||||
# a panel references the semantic layer either by raw SQL (references discovered by parsing)
|
||||
# or by declared `cube` + `measures`.
|
||||
dashboards:
|
||||
- name: exec-weekly
|
||||
title: Executive weekly
|
||||
panels:
|
||||
- name: mrr-trend
|
||||
cube: mrr_metrics
|
||||
measures: [mrr]
|
||||
- name: revenue-by-country
|
||||
sql: >
|
||||
SELECT c.country, SUM(o.order_total) AS revenue
|
||||
FROM orders o JOIN customers c ON o.customer_id = c.id
|
||||
GROUP BY c.country
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"id": "driftwood-seed42-v1",
|
||||
"repository": "Canner/Warble",
|
||||
"release_tag": "eval-fixtures-v1",
|
||||
"asset_name": "driftwood-seed42-2026-06-30-v1.duckdb",
|
||||
"sha256": "3adbf0d7d7cb82331667a2cb2c37d66bc1a46f8f3bb4f5658df70a1449a277dd",
|
||||
"size_bytes": 22032384,
|
||||
"generator": {
|
||||
"base_fixture_version": 1,
|
||||
"seed": 42,
|
||||
"reference_date": "2026-06-30",
|
||||
"source_commit": "cdd42438af417bd2ccba854aac87a0617c9a47d1"
|
||||
},
|
||||
"expectations": {
|
||||
"table_count": 18,
|
||||
"total_rows": 693531,
|
||||
"subscription_snapshots_max_date": "2026-06-30"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch and verify the pinned synthetic Driftwood base fixture.
|
||||
|
||||
The release asset is immutable input for ordinary eval preparation. Updating
|
||||
it is an explicit refresh operation: publish a new asset/tag, then update the
|
||||
committed lock. A missing asset or checksum mismatch always fails; this helper
|
||||
never falls back to the expensive generator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import BinaryIO
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DEFAULT_LOCK = HERE / "fixture.lock.json"
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
HTTP_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
class FixtureError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def load_lock(path: Path) -> dict:
|
||||
try:
|
||||
lock = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise FixtureError(f"cannot read fixture lock {path}: {exc}") from exc
|
||||
|
||||
required = {
|
||||
"format_version",
|
||||
"id",
|
||||
"repository",
|
||||
"release_tag",
|
||||
"asset_name",
|
||||
"sha256",
|
||||
"size_bytes",
|
||||
"generator",
|
||||
"expectations",
|
||||
}
|
||||
missing = required - set(lock)
|
||||
if missing:
|
||||
raise FixtureError(f"fixture lock is missing fields: {sorted(missing)}")
|
||||
if lock["format_version"] != 1:
|
||||
raise FixtureError(f"unsupported fixture lock version: {lock['format_version']}")
|
||||
if len(lock["sha256"]) != 64:
|
||||
raise FixtureError("fixture lock sha256 must contain 64 hexadecimal characters")
|
||||
try:
|
||||
int(lock["sha256"], 16)
|
||||
except ValueError as exc:
|
||||
raise FixtureError("fixture lock sha256 is not hexadecimal") from exc
|
||||
return lock
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(CHUNK_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_file(path: Path, lock: dict) -> None:
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError as exc:
|
||||
raise FixtureError(f"fixture is unavailable at {path}: {exc}") from exc
|
||||
if size != lock["size_bytes"]:
|
||||
raise FixtureError(
|
||||
f"fixture size mismatch for {path}: expected {lock['size_bytes']}, found {size}"
|
||||
)
|
||||
actual = sha256_file(path)
|
||||
if actual != lock["sha256"]:
|
||||
raise FixtureError(
|
||||
f"fixture checksum mismatch for {path}: expected {lock['sha256']}, found {actual}"
|
||||
)
|
||||
|
||||
|
||||
def default_cache_root() -> Path:
|
||||
explicit = os.environ.get("WARBLE_EVAL_FIXTURE_CACHE")
|
||||
if explicit:
|
||||
return Path(explicit).expanduser()
|
||||
xdg = os.environ.get("XDG_CACHE_HOME")
|
||||
base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
|
||||
return base / "warble" / "eval-fixtures"
|
||||
|
||||
|
||||
def _auth_token() -> str | None:
|
||||
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
if shutil.which("gh") is None:
|
||||
return None
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "token"],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
token = result.stdout.strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _request(url: str, token: str | None, accept: str) -> urllib.request.Request:
|
||||
headers = {
|
||||
"Accept": accept,
|
||||
"User-Agent": "warble-eval-fixture/1",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def _copy_response(response: BinaryIO, destination: Path) -> None:
|
||||
with destination.open("wb") as output:
|
||||
while True:
|
||||
chunk = response.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
output.write(chunk)
|
||||
|
||||
|
||||
def _download_release_asset(lock: dict, destination: Path) -> None:
|
||||
token = _auth_token()
|
||||
release_url = (
|
||||
f"https://api.github.com/repos/{lock['repository']}/releases/tags/"
|
||||
f"{lock['release_tag']}"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
_request(release_url, token, "application/vnd.github+json"),
|
||||
timeout=HTTP_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
release = json.load(response)
|
||||
asset = next(
|
||||
(item for item in release.get("assets", []) if item.get("name") == lock["asset_name"]),
|
||||
None,
|
||||
)
|
||||
if asset is None:
|
||||
raise FixtureError(
|
||||
f"release {lock['repository']}@{lock['release_tag']} has no asset "
|
||||
f"named {lock['asset_name']}"
|
||||
)
|
||||
with urllib.request.urlopen(
|
||||
_request(asset["url"], token, "application/octet-stream"),
|
||||
timeout=HTTP_TIMEOUT_SECONDS,
|
||||
) as response:
|
||||
_copy_response(response, destination)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise FixtureError(
|
||||
f"fixture download failed with HTTP {exc.code}; verify release/tag access"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise FixtureError(f"fixture download failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def cached_path(lock: dict, cache_root: Path) -> Path:
|
||||
cache_key = f"{lock['id']}-{lock['sha256']}"
|
||||
return cache_root / cache_key / lock["asset_name"]
|
||||
|
||||
|
||||
def fetch(lock: dict, cache_root: Path) -> tuple[Path, bool]:
|
||||
target = cached_path(lock, cache_root)
|
||||
if target.is_file():
|
||||
try:
|
||||
verify_file(target, lock)
|
||||
return target, True
|
||||
except FixtureError:
|
||||
# A fresh verified download atomically replaces the corrupt entry.
|
||||
pass
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Keep the temporary download on the cache filesystem so os.replace is
|
||||
# atomic even when the system temp directory is a different mount.
|
||||
with tempfile.TemporaryDirectory(prefix=".download-", dir=target.parent) as temp_dir:
|
||||
downloaded = Path(temp_dir) / lock["asset_name"]
|
||||
_download_release_asset(lock, downloaded)
|
||||
verify_file(downloaded, lock)
|
||||
os.replace(downloaded, target)
|
||||
return target, False
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
fetch_parser = subparsers.add_parser("fetch", help="download once, verify, and reuse the cache")
|
||||
fetch_parser.add_argument("--cache-dir", type=Path, default=None)
|
||||
fetch_parser.add_argument("--output", type=Path, default=None)
|
||||
|
||||
verify_parser = subparsers.add_parser("verify", help="verify a local file against the lock")
|
||||
verify_parser.add_argument("path", type=Path)
|
||||
|
||||
subparsers.add_parser("describe", help="print the committed fixture identity")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
lock = load_lock(args.lock)
|
||||
if args.command == "verify":
|
||||
verify_file(args.path, lock)
|
||||
print(args.path)
|
||||
return 0
|
||||
if args.command == "describe":
|
||||
print(json.dumps(lock, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
cache_root = args.cache_dir or default_cache_root()
|
||||
path, hit = fetch(lock, cache_root)
|
||||
print(f"fixture cache {'hit' if hit else 'miss'}: {path}", file=sys.stderr)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, args.output)
|
||||
print(args.output)
|
||||
else:
|
||||
print(path)
|
||||
return 0
|
||||
except FixtureError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
+1
@@ -0,0 +1 @@
|
||||
schema_version: 1
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
# Canonical metric definitions (Driftwood)
|
||||
|
||||
These are the company's authoritative definitions. When a question asks for one of these
|
||||
metrics, use exactly this definition — do not improvise an alternative.
|
||||
|
||||
## Recognized revenue / gross captured volume
|
||||
|
||||
- **Gross captured payment volume** = `SUM(payments.amount + payments.fee_amount)` over
|
||||
payment rows with `method <> 'refund_reversal'`. (`payments.amount` alone is NET of
|
||||
processor fees; `orders.order_total` is a header estimate, not money movement.)
|
||||
- **Refunds are subtracted from the `refunds` table only.** Every refund exists twice:
|
||||
as a `refunds` row (positive, gross) AND as a negative `payments` row
|
||||
(`method = 'refund_reversal'`). Counting both double-subtracts. Convention: exclude
|
||||
`refund_reversal` rows from payment sums; take refund totals from `refunds`.
|
||||
- **Net recognized revenue** = gross captured volume − `SUM(refunds.amount)`, both in USD.
|
||||
|
||||
## Currency conversion
|
||||
|
||||
- Reporting currency is **USD**. Convert EUR/GBP amounts at the `fx_rates` rate for the
|
||||
transaction date; `fx_rates` has business days only — for weekend dates use the **latest
|
||||
prior business day's** rate (ASOF/last-value join on `fx_rates.date <= txn_date` per currency).
|
||||
|
||||
## Merchandise value (order value at line grain)
|
||||
|
||||
- Canonical order/merchandise value is computed at **line-item grain**:
|
||||
`SUM(order_items.quantity * order_items.unit_price - order_items.discount_amount)`.
|
||||
- `orders.order_total` disagrees with the line-item sum on ~8% of orders (rounding,
|
||||
partial-refund adjustments). For reconciliation-sensitive answers, line items win.
|
||||
|
||||
## MRR (monthly recurring revenue)
|
||||
|
||||
- MRR is a **point-in-time balance**, read from `subscription_snapshots` at a single
|
||||
`snapshot_date` (month-end): `SUM(mrr_amount) WHERE snapshot_date = <month end>`.
|
||||
- **Never sum mrr_amount across snapshot dates.** "MRR for December 2025" means the
|
||||
2025-12-31 snapshot. "Average MRR over a year" means the average of the 12 month-end
|
||||
totals. The same point-in-time rule applies to `inventory_levels.units_on_hand`.
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# Reporting conventions (Driftwood)
|
||||
|
||||
## Default filters
|
||||
|
||||
- **Exclude test accounts from EVERY business metric** — customer counts, order counts,
|
||||
revenue, units sold, returns, subscriptions — unless the question explicitly asks about
|
||||
internal/test accounts. Test accounts are `customers.is_test = TRUE`; apply the exclusion
|
||||
wherever a customer link exists (orders/payments/refunds/returns join back to the
|
||||
customer). Legacy tables carry no test flag, so no exclusion is possible there.
|
||||
- **Soft deletes**: `customers.deleted_at IS NOT NULL` rows still exist in the table.
|
||||
"Active customers" excludes them; counts of accounts/customers "we have (ever) had"
|
||||
include them (but still exclude test accounts).
|
||||
|
||||
## Reporting timezone
|
||||
|
||||
All date bucketing and calendar filters (year/quarter/month/day) are in **UTC**.
|
||||
New-platform timestamps are already UTC. Legacy naive America/Los_Angeles timestamps must
|
||||
be converted to UTC before any bucketing — including for legacy-only questions.
|
||||
|
||||
## Units sold
|
||||
|
||||
"Units sold" = the sum of order-line quantities, **excluding cancelled orders** (they were
|
||||
never fulfilled). Refunded orders still count as sold — the money side is handled via
|
||||
refunds, not by un-counting the sale.
|
||||
|
||||
## Marketing channel canonicalization
|
||||
|
||||
`customers.marketing_channel` contains historical variants. Canonical grouping:
|
||||
|
||||
| canonical | raw values |
|
||||
| --- | --- |
|
||||
| Facebook | 'fb', 'facebook', 'Facebook Ads' |
|
||||
| Google | 'google', 'adwords', 'Google' |
|
||||
| Email | 'email', 'newsletter' |
|
||||
| Organic | 'organic' |
|
||||
| Unknown | NULL |
|
||||
|
||||
Any channel question must aggregate over the canonical groups, not the raw strings.
|
||||
|
||||
## Order status normalization
|
||||
|
||||
`orders.status` is lowercase except a small share of historical mixed-case rows
|
||||
('PAID', 'Paid'). Always compare case-insensitively: `lower(status) = 'paid'`.
|
||||
|
||||
## Fiscal calendar
|
||||
|
||||
The fiscal year starts **February 1** and is labeled by its starting year
|
||||
(FY2024 = 2024-02-01 → 2025-01-31; Q1 = Feb–Apr, Q2 = May–Jul, Q3 = Aug–Oct,
|
||||
Q4 = Nov–Jan). Any "Qn" or "fiscal year" question must go through `fiscal_calendar`
|
||||
— never assume calendar quarters.
|
||||
|
||||
## Returns vs refunds (two different things)
|
||||
|
||||
- **Returns** (`returns`) = physical merchandise coming back (RMA), measured in units
|
||||
(`qty_returned`). Use for return rates and item counts.
|
||||
- **Refunds** (`refunds`) = money given back, measured in currency. Use for refund
|
||||
amounts.
|
||||
- They overlap only partially (a refund can happen without a return and vice versa);
|
||||
never join or substitute one for the other.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Cross-system rules: legacy platform vs new platform
|
||||
|
||||
Driftwood migrated platforms in 2023. Legacy (`legacy_*` tables) covers 2019-01 → 2023-08;
|
||||
the new platform (`orders`, `customers`, …) covers 2023-03 → present. Any all-time or
|
||||
pre-2023 question must combine both systems under these rules.
|
||||
|
||||
## Order dedup (the migration copied orders)
|
||||
|
||||
- Legacy orders from the migration window were **copied into `orders`**: the copy carries
|
||||
`orders.legacy_ord_id = legacy_orders.ord_id`, and the source carries
|
||||
`legacy_orders.migrated_at IS NOT NULL`.
|
||||
- **Counting rule**: all-time order counts/sums = all of `legacy_orders` + only
|
||||
`orders WHERE legacy_ord_id IS NULL` (or equivalently: all of `orders` + only
|
||||
`legacy_orders WHERE migrated_at IS NULL`). Never a plain UNION of both tables.
|
||||
|
||||
## Legacy amounts and timestamps
|
||||
|
||||
- `legacy_orders.amt_c` is integer **US cents** — divide by 100.0. Legacy is USD-only.
|
||||
- `legacy_orders.ord_dt` / `ship_dt` are naive **America/Los_Angeles local time** strings.
|
||||
Convert to UTC before any date bucketing or comparison — the reporting timezone is
|
||||
always UTC (see conventions), including for legacy-only questions.
|
||||
- `legacy_orders.ship_dt = '1970-01-01 00:00:00'` is a sentinel meaning **never shipped**
|
||||
— treat it as NULL, never as a real date.
|
||||
- Legacy statuses are numeric codes: decode via `legacy_status_codes`
|
||||
(1 pending, 2 paid, 3 shipped, 4 delivered, 5 partial, 7 refunded, 9 cancelled).
|
||||
|
||||
## Customer identity across systems
|
||||
|
||||
- Many legacy customers are the **same person** as a new-platform customer.
|
||||
`customer_xref` maps them but is **incomplete** (~87% of the true overlap).
|
||||
- **Identity rule**: resolve identity by normalized email —
|
||||
`lower(trim(email))` matches across `legacy_customers` and `customers` — and use
|
||||
`customer_xref` only as a supplement. Unique-customer counts = new-platform customers
|
||||
(excluding test accounts) + legacy customers whose normalized email has no
|
||||
new-platform match.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Business rules
|
||||
|
||||
Add custom rules or guidelines for LLM-based query generation here.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
nl: Monthly MRR trend from month-end snapshots
|
||||
sql: SELECT snapshot_date, mrr FROM mrr_metrics GROUP BY snapshot_date ORDER BY snapshot_date
|
||||
source: user
|
||||
tags:
|
||||
- source:enrich
|
||||
---
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
nl: Order count by status on the new platform
|
||||
sql: SELECT status, COUNT(*) AS order_count FROM orders GROUP BY status
|
||||
source: user
|
||||
tags:
|
||||
- source:enrich
|
||||
---
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
name: customer_xref
|
||||
properties:
|
||||
description: "Cross-reference mapping legacy customers to new-platform customers. INCOMPLETE: covers only ~87% of the true overlap; email normalization finds the rest."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: customer_xref
|
||||
columns:
|
||||
- name: cust_ref
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "FK to legacy_customers.cust_ref."
|
||||
- name: customer_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to customers.id."
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
name: customers
|
||||
properties:
|
||||
description: "New-platform customers (2023-03 onward). One row per account, including soft-deleted and internal test accounts."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: customers
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique customer id (new platform)."
|
||||
- name: email
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Account email as entered; casing/whitespace not normalized."
|
||||
- name: full_name
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Customer display name."
|
||||
- name: country
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "ISO-ish country code: US, DE, FR, GB."
|
||||
- name: created_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Account creation time (UTC)."
|
||||
- name: deleted_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Soft-delete timestamp (UTC); NULL = active. Deleted rows remain in the table."
|
||||
- name: is_test
|
||||
type: BOOLEAN
|
||||
properties:
|
||||
description: "TRUE for internal test accounts (emails with '+test' or @driftwood.example)."
|
||||
- name: marketing_channel
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Free-text acquisition channel; contains historical spelling variants (e.g. 'fb' / 'facebook' / 'Facebook Ads' are the same channel) and NULLs."
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
name: fiscal_calendar
|
||||
properties:
|
||||
description: "Company fiscal calendar. The fiscal year starts Feb 1 and is labeled by its starting year (FY2024 = 2024-02-01 through 2025-01-31)."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: fiscal_calendar
|
||||
primary_key: date
|
||||
columns:
|
||||
- name: date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Calendar date."
|
||||
- name: fiscal_year
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Fiscal year label (year in which the FY starts)."
|
||||
- name: fiscal_quarter
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Fiscal quarter label, e.g. 'FY2024-Q3' (Q1 = Feb-Apr)."
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
name: fx_rates
|
||||
properties:
|
||||
description: "Daily FX rates to USD, BUSINESS DAYS ONLY (no weekend rows) — for a weekend date use the latest prior business day."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: fx_rates
|
||||
columns:
|
||||
- name: date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Rate date (weekdays only)."
|
||||
- name: currency
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Quoted currency: EUR or GBP."
|
||||
- name: usd_rate
|
||||
type: "DECIMAL(12,6)"
|
||||
properties:
|
||||
description: "USD per 1 unit of the quoted currency."
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
name: inventory_levels
|
||||
properties:
|
||||
description: "Month-end inventory snapshots per warehouse and product. units_on_hand is a BALANCE as of snapshot_date — not additive across dates."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: inventory_levels
|
||||
columns:
|
||||
- name: snapshot_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Snapshot date; always the last day of a calendar month."
|
||||
- name: warehouse_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to warehouses.id."
|
||||
- name: product_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to products.id."
|
||||
- name: units_on_hand
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Units in stock at snapshot time (point-in-time balance)."
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
name: legacy_customers
|
||||
properties:
|
||||
description: "Customers from the legacy platform. Many are the same people as new-platform customers, with email casing/whitespace variants; customer_xref maps only part of them."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: legacy_customers
|
||||
primary_key: cust_ref
|
||||
columns:
|
||||
- name: cust_ref
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Legacy customer key, e.g. 'C-04217'."
|
||||
- name: email
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Email as entered in the legacy system; casing/whitespace may differ from the same person's new-platform email."
|
||||
- name: signup_dt
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Legacy signup date."
|
||||
- name: region
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Legacy sales region: west, east, or eu."
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
name: legacy_orders
|
||||
properties:
|
||||
description: "Orders from the legacy platform (2019-01 to 2023-08, USD only). Orders in the 2023 migration window may ALSO exist as copies in the new orders table (see migrated_at)."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: legacy_orders
|
||||
primary_key: ord_id
|
||||
columns:
|
||||
- name: ord_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique legacy order id."
|
||||
- name: cust_ref
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Legacy customer key (FK to legacy_customers.cust_ref), e.g. 'C-04217'."
|
||||
- name: ord_dt
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Order timestamp as a NAIVE LOCAL-TIME string ('YYYY-MM-DD HH:MM:SS') in America/Los_Angeles — convert to UTC before comparing with new-platform timestamps."
|
||||
- name: amt_c
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Order amount in integer US CENTS (divide by 100.0 for dollars). USD only."
|
||||
- name: stat
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Numeric status code; decode via legacy_status_codes."
|
||||
- name: ship_dt
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Ship timestamp string; the sentinel '1970-01-01 00:00:00' means never shipped (the legacy system did not use NULL)."
|
||||
- name: migrated_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Non-NULL when this order was copied into the new platform's orders table during the 2023 migration (the copy carries legacy_ord_id = this ord_id)."
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
name: legacy_status_codes
|
||||
properties:
|
||||
description: "Lookup for legacy_orders.stat numeric codes. Code 5 ('partial') was historically undocumented."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: legacy_status_codes
|
||||
primary_key: code
|
||||
columns:
|
||||
- name: code
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Numeric status code used by legacy_orders.stat."
|
||||
- name: label
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Human-readable status label."
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
name: order_items
|
||||
properties:
|
||||
description: "Order line items (new platform). One row per product line within an order."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: order_items
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Surrogate line-item id."
|
||||
- name: order_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to orders.id."
|
||||
- name: product_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to products.id."
|
||||
- name: quantity
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Units ordered on this line."
|
||||
- name: unit_price
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Per-unit price in the order's currency."
|
||||
- name: discount_amount
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Line-level discount in the order's currency (0 if none)."
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
name: orders
|
||||
properties:
|
||||
description: "New-platform orders (2023-03 onward). One row per order header. Includes copies of legacy orders migrated during 2023 (marked by legacy_ord_id)."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: orders
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique order id (new platform)."
|
||||
- name: customer_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to customers.id."
|
||||
- name: currency
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Order currency: USD, EUR, or GBP."
|
||||
- name: order_total
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Header-level gross order total (incl. tax) in the order's currency. Can differ slightly from the sum of line items on a small share of orders."
|
||||
- name: status
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Order status: pending, paid, shipped, delivered, cancelled, refunded. A small share of historical rows use mixed case (e.g. 'PAID', 'Paid')."
|
||||
- name: placed_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Order placement time (UTC)."
|
||||
- name: shipped_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Ship time (UTC); NULL = not shipped."
|
||||
- name: legacy_ord_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Non-NULL when this row is a migrated copy of a legacy order (FK to legacy_orders.ord_id). Such orders exist in BOTH tables."
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
name: payments
|
||||
properties:
|
||||
description: "Captured payments (new platform). One row per capture attempt; refund reversals appear as negative-amount rows."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: payments
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique payment id."
|
||||
- name: order_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to orders.id."
|
||||
- name: amount
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Payment amount NET of processor fees, in the payment currency. NOTE: different meaning from orders.order_total (gross). Refund reversals are negative amounts with method='refund_reversal' and also exist in the refunds table."
|
||||
- name: fee_amount
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Processor fee for this capture; gross captured = amount + fee_amount."
|
||||
- name: currency
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Payment currency (matches the order's currency)."
|
||||
- name: captured_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Capture time (UTC)."
|
||||
- name: method
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "card, paypal, apple_pay, or refund_reversal (negative rows)."
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
name: products
|
||||
properties:
|
||||
description: "Product catalog. One row per SKU."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: products
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique product id."
|
||||
- name: sku
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Stock keeping unit code."
|
||||
- name: name
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Product display name."
|
||||
- name: category
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Hierarchical category as a single string, e.g. 'Camping > Tents'."
|
||||
- name: current_price
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Current list price in USD."
|
||||
- name: cost
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Unit cost in USD."
|
||||
- name: introduced_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "When the product entered the catalog (UTC)."
|
||||
- name: discontinued_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Non-NULL = discontinued at this time (UTC)."
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
name: refunds
|
||||
properties:
|
||||
description: "Money refunds (new platform). One row per refund, gross amount, positive sign. Each refund is ALSO mirrored as a negative payments row."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: refunds
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique refund id."
|
||||
- name: order_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to orders.id."
|
||||
- name: amount
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Refunded amount, GROSS and positive, in the order's currency."
|
||||
- name: reason
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Free-text refund reason."
|
||||
- name: refunded_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Refund time (UTC)."
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
name: returns
|
||||
properties:
|
||||
description: "Merchandise returns (RMA) at the order-line level. Distinct from refunds: returns track physical items coming back; refunds track money."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: returns
|
||||
primary_key: rma_id
|
||||
columns:
|
||||
- name: rma_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique return authorization id."
|
||||
- name: order_item_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to order_items.id."
|
||||
- name: qty_returned
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Units physically returned on this RMA."
|
||||
- name: received_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "When the returned items were received (UTC)."
|
||||
- name: disposition
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "restock, damaged, or disposed."
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
name: subscription_snapshots
|
||||
properties:
|
||||
description: "Month-end point-in-time snapshots of active subscriptions. mrr_amount is a BALANCE as of snapshot_date — it is not additive across dates."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: subscription_snapshots
|
||||
columns:
|
||||
- name: snapshot_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Snapshot date; always the last day of a calendar month."
|
||||
- name: subscription_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to subscriptions.id."
|
||||
- name: mrr_amount
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Monthly recurring revenue attributed to this subscription as of the snapshot date (USD). Point-in-time balance; never sum across snapshot dates."
|
||||
- name: status
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Subscription status at snapshot time."
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
name: subscriptions
|
||||
properties:
|
||||
description: "Driftwood+ gear-rental subscriptions (launched 2024-01). One row per subscription."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: subscriptions
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique subscription id."
|
||||
- name: customer_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to customers.id."
|
||||
- name: plan
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "plus_monthly or plus_annual."
|
||||
- name: started_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Subscription start (UTC)."
|
||||
- name: canceled_at
|
||||
type: TIMESTAMP
|
||||
properties:
|
||||
description: "Cancellation time (UTC); NULL = still active."
|
||||
- name: monthly_price
|
||||
type: "DECIMAL(12,2)"
|
||||
properties:
|
||||
description: "Monthly recurring price in USD (annual plans stored as the monthly-equivalent amount)."
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
name: warehouses
|
||||
properties:
|
||||
description: "Fulfillment warehouses."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: warehouses
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "Unique warehouse id."
|
||||
- name: name
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Warehouse name."
|
||||
- name: country
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Warehouse country (US or DE)."
|
||||
- name: tz
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "IANA timezone of the warehouse."
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
name: web_events
|
||||
properties:
|
||||
description: "Website behavioral events. High-volume append-only log."
|
||||
table_reference:
|
||||
catalog: driftwood
|
||||
schema: main
|
||||
table: web_events
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: BIGINT
|
||||
properties:
|
||||
description: "Unique event id."
|
||||
- name: event_time
|
||||
type: BIGINT
|
||||
properties:
|
||||
description: "Event timestamp as EPOCH MILLISECONDS (BIGINT, UTC)."
|
||||
- name: customer_id
|
||||
type: INTEGER
|
||||
properties:
|
||||
description: "FK to customers.id; NULL for anonymous sessions."
|
||||
- name: event_type
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "page_view, add_to_cart, checkout, or search."
|
||||
- name: session_id
|
||||
type: VARCHAR
|
||||
properties:
|
||||
description: "Browser session identifier."
|
||||
@@ -0,0 +1,82 @@
|
||||
# Driftwood relationships. Note: fx_rates joins on (date, currency) with a business-day
|
||||
# fill convention — not expressible as an FK equality here; see knowledge/rules/.
|
||||
relationships:
|
||||
- name: orders_customers
|
||||
models: [orders, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "orders.customer_id = customers.id"
|
||||
|
||||
- name: order_items_orders
|
||||
models: [order_items, orders]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "order_items.order_id = orders.id"
|
||||
|
||||
- name: order_items_products
|
||||
models: [order_items, products]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "order_items.product_id = products.id"
|
||||
|
||||
- name: payments_orders
|
||||
models: [payments, orders]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "payments.order_id = orders.id"
|
||||
|
||||
- name: refunds_orders
|
||||
models: [refunds, orders]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "refunds.order_id = orders.id"
|
||||
|
||||
- name: subscriptions_customers
|
||||
models: [subscriptions, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "subscriptions.customer_id = customers.id"
|
||||
|
||||
- name: subscription_snapshots_subscriptions
|
||||
models: [subscription_snapshots, subscriptions]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "subscription_snapshots.subscription_id = subscriptions.id"
|
||||
|
||||
- name: web_events_customers
|
||||
models: [web_events, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "web_events.customer_id = customers.id"
|
||||
|
||||
- name: inventory_levels_warehouses
|
||||
models: [inventory_levels, warehouses]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "inventory_levels.warehouse_id = warehouses.id"
|
||||
|
||||
- name: inventory_levels_products
|
||||
models: [inventory_levels, products]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "inventory_levels.product_id = products.id"
|
||||
|
||||
- name: legacy_orders_legacy_customers
|
||||
models: [legacy_orders, legacy_customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "legacy_orders.cust_ref = legacy_customers.cust_ref"
|
||||
|
||||
- name: legacy_orders_status_codes
|
||||
models: [legacy_orders, legacy_status_codes]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "legacy_orders.stat = legacy_status_codes.code"
|
||||
|
||||
- name: orders_legacy_orders
|
||||
models: [orders, legacy_orders]
|
||||
join_type: ONE_TO_ONE
|
||||
condition: "orders.legacy_ord_id = legacy_orders.ord_id"
|
||||
|
||||
- name: customer_xref_customers
|
||||
models: [customer_xref, customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "customer_xref.customer_id = customers.id"
|
||||
|
||||
- name: customer_xref_legacy_customers
|
||||
models: [customer_xref, legacy_customers]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "customer_xref.cust_ref = legacy_customers.cust_ref"
|
||||
|
||||
- name: returns_order_items
|
||||
models: [returns, order_items]
|
||||
join_type: MANY_TO_ONE
|
||||
condition: "returns.order_item_id = order_items.id"
|
||||
@@ -0,0 +1,299 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Emit models/<table>/metadata.yml for the driftwood wren project.
|
||||
|
||||
Column types come from schema_dump.csv (authoritative, introspected from the DB);
|
||||
descriptions live in the DESC dict below. Fails loudly if any column in the dump
|
||||
lacks a description (or vice versa), so the two can't drift.
|
||||
|
||||
Description policy (matters for the eval design): MDL descriptions carry truthful
|
||||
*local* facts only (units, timezone, sentinels, net-vs-gross, snapshot grain).
|
||||
Cross-cutting *business rules* (canonical revenue formula, dedup rule, fiscal
|
||||
calendar usage, identity resolution) deliberately live in knowledge/rules/ —
|
||||
that split is the schema-only vs schema+knowledge injection axis for this Wren fixture.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
# table -> (table_description, primary_key or None, {column: description})
|
||||
DESC: dict[str, tuple[str, str | None, dict[str, str]]] = {
|
||||
"customers": (
|
||||
"New-platform customers (2023-03 onward). One row per account, including "
|
||||
"soft-deleted and internal test accounts.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique customer id (new platform).",
|
||||
"email": "Account email as entered; casing/whitespace not normalized.",
|
||||
"full_name": "Customer display name.",
|
||||
"country": "ISO-ish country code: US, DE, FR, GB.",
|
||||
"created_at": "Account creation time (UTC).",
|
||||
"deleted_at": "Soft-delete timestamp (UTC); NULL = active. Deleted rows remain in the table.",
|
||||
"is_test": "TRUE for internal test accounts (emails with '+test' or @driftwood.example).",
|
||||
"marketing_channel": "Free-text acquisition channel; contains historical spelling variants "
|
||||
"(e.g. 'fb' / 'facebook' / 'Facebook Ads' are the same channel) and NULLs.",
|
||||
},
|
||||
),
|
||||
"orders": (
|
||||
"New-platform orders (2023-03 onward). One row per order header. Includes copies of "
|
||||
"legacy orders migrated during 2023 (marked by legacy_ord_id).",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique order id (new platform).",
|
||||
"customer_id": "FK to customers.id.",
|
||||
"currency": "Order currency: USD, EUR, or GBP.",
|
||||
"order_total": "Header-level gross order total (incl. tax) in the order's currency. "
|
||||
"Can differ slightly from the sum of line items on a small share of orders.",
|
||||
"status": "Order status: pending, paid, shipped, delivered, cancelled, refunded. "
|
||||
"A small share of historical rows use mixed case (e.g. 'PAID', 'Paid').",
|
||||
"placed_at": "Order placement time (UTC).",
|
||||
"shipped_at": "Ship time (UTC); NULL = not shipped.",
|
||||
"legacy_ord_id": "Non-NULL when this row is a migrated copy of a legacy order "
|
||||
"(FK to legacy_orders.ord_id). Such orders exist in BOTH tables.",
|
||||
},
|
||||
),
|
||||
"order_items": (
|
||||
"Order line items (new platform). One row per product line within an order.",
|
||||
"id",
|
||||
{
|
||||
"id": "Surrogate line-item id.",
|
||||
"order_id": "FK to orders.id.",
|
||||
"product_id": "FK to products.id.",
|
||||
"quantity": "Units ordered on this line.",
|
||||
"unit_price": "Per-unit price in the order's currency.",
|
||||
"discount_amount": "Line-level discount in the order's currency (0 if none).",
|
||||
},
|
||||
),
|
||||
"payments": (
|
||||
"Captured payments (new platform). One row per capture attempt; refund reversals "
|
||||
"appear as negative-amount rows.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique payment id.",
|
||||
"order_id": "FK to orders.id.",
|
||||
"amount": "Payment amount NET of processor fees, in the payment currency. "
|
||||
"NOTE: different meaning from orders.order_total (gross). Refund reversals are "
|
||||
"negative amounts with method='refund_reversal' and also exist in the refunds table.",
|
||||
"fee_amount": "Processor fee for this capture; gross captured = amount + fee_amount.",
|
||||
"currency": "Payment currency (matches the order's currency).",
|
||||
"captured_at": "Capture time (UTC).",
|
||||
"method": "card, paypal, apple_pay, or refund_reversal (negative rows).",
|
||||
},
|
||||
),
|
||||
"refunds": (
|
||||
"Money refunds (new platform). One row per refund, gross amount, positive sign. "
|
||||
"Each refund is ALSO mirrored as a negative payments row.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique refund id.",
|
||||
"order_id": "FK to orders.id.",
|
||||
"amount": "Refunded amount, GROSS and positive, in the order's currency.",
|
||||
"reason": "Free-text refund reason.",
|
||||
"refunded_at": "Refund time (UTC).",
|
||||
},
|
||||
),
|
||||
"products": (
|
||||
"Product catalog. One row per SKU.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique product id.",
|
||||
"sku": "Stock keeping unit code.",
|
||||
"name": "Product display name.",
|
||||
"category": "Hierarchical category as a single string, e.g. 'Camping > Tents'.",
|
||||
"current_price": "Current list price in USD.",
|
||||
"cost": "Unit cost in USD.",
|
||||
"introduced_at": "When the product entered the catalog (UTC).",
|
||||
"discontinued_at": "Non-NULL = discontinued at this time (UTC).",
|
||||
},
|
||||
),
|
||||
"subscriptions": (
|
||||
"Driftwood+ gear-rental subscriptions (launched 2024-01). One row per subscription.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique subscription id.",
|
||||
"customer_id": "FK to customers.id.",
|
||||
"plan": "plus_monthly or plus_annual.",
|
||||
"started_at": "Subscription start (UTC).",
|
||||
"canceled_at": "Cancellation time (UTC); NULL = still active.",
|
||||
"monthly_price": "Monthly recurring price in USD (annual plans stored as the "
|
||||
"monthly-equivalent amount).",
|
||||
},
|
||||
),
|
||||
"subscription_snapshots": (
|
||||
"Month-end point-in-time snapshots of active subscriptions. mrr_amount is a BALANCE "
|
||||
"as of snapshot_date — it is not additive across dates.",
|
||||
None,
|
||||
{
|
||||
"snapshot_date": "Snapshot date; always the last day of a calendar month.",
|
||||
"subscription_id": "FK to subscriptions.id.",
|
||||
"mrr_amount": "Monthly recurring revenue attributed to this subscription as of the "
|
||||
"snapshot date (USD). Point-in-time balance; never sum across snapshot dates.",
|
||||
"status": "Subscription status at snapshot time.",
|
||||
},
|
||||
),
|
||||
"web_events": (
|
||||
"Website behavioral events. High-volume append-only log.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique event id.",
|
||||
"event_time": "Event timestamp as EPOCH MILLISECONDS (BIGINT, UTC).",
|
||||
"customer_id": "FK to customers.id; NULL for anonymous sessions.",
|
||||
"event_type": "page_view, add_to_cart, checkout, or search.",
|
||||
"session_id": "Browser session identifier.",
|
||||
},
|
||||
),
|
||||
"inventory_levels": (
|
||||
"Month-end inventory snapshots per warehouse and product. units_on_hand is a BALANCE "
|
||||
"as of snapshot_date — not additive across dates.",
|
||||
None,
|
||||
{
|
||||
"snapshot_date": "Snapshot date; always the last day of a calendar month.",
|
||||
"warehouse_id": "FK to warehouses.id.",
|
||||
"product_id": "FK to products.id.",
|
||||
"units_on_hand": "Units in stock at snapshot time (point-in-time balance).",
|
||||
},
|
||||
),
|
||||
"warehouses": (
|
||||
"Fulfillment warehouses.",
|
||||
"id",
|
||||
{
|
||||
"id": "Unique warehouse id.",
|
||||
"name": "Warehouse name.",
|
||||
"country": "Warehouse country (US or DE).",
|
||||
"tz": "IANA timezone of the warehouse.",
|
||||
},
|
||||
),
|
||||
"legacy_orders": (
|
||||
"Orders from the legacy platform (2019-01 to 2023-08, USD only). Orders in the 2023 "
|
||||
"migration window may ALSO exist as copies in the new orders table (see migrated_at).",
|
||||
"ord_id",
|
||||
{
|
||||
"ord_id": "Unique legacy order id.",
|
||||
"cust_ref": "Legacy customer key (FK to legacy_customers.cust_ref), e.g. 'C-04217'.",
|
||||
"ord_dt": "Order timestamp as a NAIVE LOCAL-TIME string ('YYYY-MM-DD HH:MM:SS') in "
|
||||
"America/Los_Angeles — convert to UTC before comparing with new-platform timestamps.",
|
||||
"amt_c": "Order amount in integer US CENTS (divide by 100.0 for dollars). USD only.",
|
||||
"stat": "Numeric status code; decode via legacy_status_codes.",
|
||||
"ship_dt": "Ship timestamp string; the sentinel '1970-01-01 00:00:00' means never shipped "
|
||||
"(the legacy system did not use NULL).",
|
||||
"migrated_at": "Non-NULL when this order was copied into the new platform's orders table "
|
||||
"during the 2023 migration (the copy carries legacy_ord_id = this ord_id).",
|
||||
},
|
||||
),
|
||||
"legacy_customers": (
|
||||
"Customers from the legacy platform. Many are the same people as new-platform customers, "
|
||||
"with email casing/whitespace variants; customer_xref maps only part of them.",
|
||||
"cust_ref",
|
||||
{
|
||||
"cust_ref": "Legacy customer key, e.g. 'C-04217'.",
|
||||
"email": "Email as entered in the legacy system; casing/whitespace may differ from the "
|
||||
"same person's new-platform email.",
|
||||
"signup_dt": "Legacy signup date.",
|
||||
"region": "Legacy sales region: west, east, or eu.",
|
||||
},
|
||||
),
|
||||
"legacy_status_codes": (
|
||||
"Lookup for legacy_orders.stat numeric codes. Code 5 ('partial') was historically "
|
||||
"undocumented.",
|
||||
"code",
|
||||
{
|
||||
"code": "Numeric status code used by legacy_orders.stat.",
|
||||
"label": "Human-readable status label.",
|
||||
},
|
||||
),
|
||||
"customer_xref": (
|
||||
"Cross-reference mapping legacy customers to new-platform customers. INCOMPLETE: covers "
|
||||
"only ~87% of the true overlap; email normalization finds the rest.",
|
||||
None,
|
||||
{
|
||||
"cust_ref": "FK to legacy_customers.cust_ref.",
|
||||
"customer_id": "FK to customers.id.",
|
||||
},
|
||||
),
|
||||
"fx_rates": (
|
||||
"Daily FX rates to USD, BUSINESS DAYS ONLY (no weekend rows) — for a weekend date use "
|
||||
"the latest prior business day.",
|
||||
None,
|
||||
{
|
||||
"date": "Rate date (weekdays only).",
|
||||
"currency": "Quoted currency: EUR or GBP.",
|
||||
"usd_rate": "USD per 1 unit of the quoted currency.",
|
||||
},
|
||||
),
|
||||
"fiscal_calendar": (
|
||||
"Company fiscal calendar. The fiscal year starts Feb 1 and is labeled by its starting "
|
||||
"year (FY2024 = 2024-02-01 through 2025-01-31).",
|
||||
"date",
|
||||
{
|
||||
"date": "Calendar date.",
|
||||
"fiscal_year": "Fiscal year label (year in which the FY starts).",
|
||||
"fiscal_quarter": "Fiscal quarter label, e.g. 'FY2024-Q3' (Q1 = Feb-Apr).",
|
||||
},
|
||||
),
|
||||
"returns": (
|
||||
"Merchandise returns (RMA) at the order-line level. Distinct from refunds: returns track "
|
||||
"physical items coming back; refunds track money.",
|
||||
"rma_id",
|
||||
{
|
||||
"rma_id": "Unique return authorization id.",
|
||||
"order_item_id": "FK to order_items.id.",
|
||||
"qty_returned": "Units physically returned on this RMA.",
|
||||
"received_at": "When the returned items were received (UTC).",
|
||||
"disposition": "restock, damaged, or disposed.",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cols: dict[str, list[tuple[str, str]]] = {}
|
||||
with open(HERE / "schema_dump.csv", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
cols.setdefault(row["table_name"], []).append((row["column_name"], row["data_type"]))
|
||||
|
||||
if set(cols) != set(DESC):
|
||||
sys.exit(f"table mismatch: db-only={set(cols) - set(DESC)} desc-only={set(DESC) - set(cols)}")
|
||||
|
||||
def q(s: str) -> str:
|
||||
return '"' + s.replace('"', '\\"') + '"'
|
||||
|
||||
for table, columns in cols.items():
|
||||
table_desc, pk, col_desc = DESC[table]
|
||||
db_cols = {c for c, _ in columns}
|
||||
if db_cols != set(col_desc):
|
||||
sys.exit(
|
||||
f"{table}: column mismatch: db-only={db_cols - set(col_desc)} "
|
||||
f"desc-only={set(col_desc) - db_cols}"
|
||||
)
|
||||
lines = [
|
||||
f"name: {table}",
|
||||
"properties:",
|
||||
f" description: {q(table_desc)}",
|
||||
"table_reference:",
|
||||
" catalog: driftwood",
|
||||
" schema: main",
|
||||
f" table: {table}",
|
||||
]
|
||||
if pk:
|
||||
lines.append(f"primary_key: {pk}")
|
||||
lines.append("columns:")
|
||||
for cname, ctype in columns:
|
||||
lines += [
|
||||
f" - name: {cname}",
|
||||
f" type: {q(ctype) if '(' in ctype else ctype}",
|
||||
" properties:",
|
||||
f" description: {q(col_desc[cname])}",
|
||||
]
|
||||
out = HERE / "models" / table / "metadata.yml"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text("\n".join(lines) + "\n")
|
||||
print(f"wrote {out.relative_to(HERE)} ({len(columns)} cols)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["duckdb", "pyyaml", "pytest"]
|
||||
# ///
|
||||
"""Tests for the pinned Driftwood fixture lock and local cache helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load_module(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
fixture = load_module("driftwood_fixture", HERE / "fixture.py")
|
||||
generate = load_module("driftwood_generate", HERE / "generate.py")
|
||||
|
||||
|
||||
def test_committed_lock_matches_base_identity():
|
||||
lock = fixture.load_lock(HERE / "fixture.lock.json")
|
||||
assert lock["id"] == generate.BASE_FIXTURE_ID
|
||||
assert lock["generator"]["base_fixture_version"] == generate.BASE_FIXTURE_VERSION
|
||||
assert lock["generator"]["seed"] == generate.SEED
|
||||
assert lock["generator"]["reference_date"] == generate.TODAY.isoformat()
|
||||
assert lock["expectations"]["subscription_snapshots_max_date"] == generate.TODAY.isoformat()
|
||||
|
||||
|
||||
def fake_lock(payload: bytes) -> dict:
|
||||
return {
|
||||
"format_version": 1,
|
||||
"id": "test-fixture-v1",
|
||||
"repository": "example/test",
|
||||
"release_tag": "fixtures-v1",
|
||||
"asset_name": "fixture.bin",
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"size_bytes": len(payload),
|
||||
"generator": {},
|
||||
"expectations": {},
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_downloads_once_then_reuses_content_addressed_cache(tmp_path, monkeypatch):
|
||||
payload = b"deterministic fixture payload"
|
||||
lock = fake_lock(payload)
|
||||
calls = []
|
||||
|
||||
def download(_lock, destination):
|
||||
calls.append(destination)
|
||||
destination.write_bytes(payload)
|
||||
|
||||
monkeypatch.setattr(fixture, "_download_release_asset", download)
|
||||
first, first_hit = fixture.fetch(lock, tmp_path)
|
||||
second, second_hit = fixture.fetch(lock, tmp_path)
|
||||
|
||||
assert first == second
|
||||
assert first.read_bytes() == payload
|
||||
assert first_hit is False
|
||||
assert second_hit is True
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_corrupt_cache_is_replaced_and_never_accepted(tmp_path, monkeypatch):
|
||||
payload = b"correct"
|
||||
lock = fake_lock(payload)
|
||||
target = fixture.cached_path(lock, tmp_path)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b"corrupt")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fixture,
|
||||
"_download_release_asset",
|
||||
lambda _lock, destination: destination.write_bytes(payload),
|
||||
)
|
||||
path, hit = fixture.fetch(lock, tmp_path)
|
||||
|
||||
assert hit is False
|
||||
assert path.read_bytes() == payload
|
||||
fixture.verify_file(path, lock)
|
||||
|
||||
|
||||
def test_bad_lock_and_bad_file_fail_loudly(tmp_path):
|
||||
bad_lock = tmp_path / "bad.json"
|
||||
bad_lock.write_text(json.dumps({"format_version": 1}))
|
||||
with pytest.raises(fixture.FixtureError, match="missing fields"):
|
||||
fixture.load_lock(bad_lock)
|
||||
|
||||
payload = b"expected"
|
||||
lock = fake_lock(payload)
|
||||
wrong = tmp_path / "wrong.bin"
|
||||
wrong.write_bytes(b"wrong")
|
||||
with pytest.raises(fixture.FixtureError, match="size mismatch"):
|
||||
fixture.verify_file(wrong, lock)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"] + sys.argv[1:]))
|
||||
@@ -0,0 +1,198 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["duckdb", "pyyaml", "pytest"]
|
||||
# ///
|
||||
"""
|
||||
pytest companion to `generate.py --verify`. Same 5 checks, as importable
|
||||
assertions rather than a print-and-exit CLI. Run directly (this script
|
||||
carries its own PEP 723 header, same convention as generate.py):
|
||||
|
||||
uv run test_generate.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import generate as gen # noqa: E402
|
||||
|
||||
SEED = gen.SEED
|
||||
|
||||
|
||||
def test_clean_generation_is_reproducible():
|
||||
d1 = gen.generate(random.Random(SEED))
|
||||
d2 = gen.generate(random.Random(SEED))
|
||||
assert d1.keys() == d2.keys()
|
||||
for table in d1:
|
||||
assert d1[table] == d2[table], f"table {table} differs across two fresh Random(SEED) runs"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
|
||||
def test_injection_is_reproducible(scenario):
|
||||
base1 = gen.generate(random.Random(SEED))
|
||||
base2 = gen.generate(random.Random(SEED))
|
||||
mutated1, inj1 = gen.SCENARIOS[scenario](base1)
|
||||
mutated2, inj2 = gen.SCENARIOS[scenario](base2)
|
||||
assert mutated1.keys() == mutated2.keys()
|
||||
for table in mutated1:
|
||||
assert mutated1[table] == mutated2[table], f"table {table} differs across two independent {scenario} runs"
|
||||
assert gen._plain(inj1) == gen._plain(inj2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
|
||||
def test_diff_matches_manifest(scenario):
|
||||
ok, detail = gen._check_diff_matches_manifest(scenario)
|
||||
assert ok, detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
|
||||
def test_manifest_well_formed(scenario):
|
||||
base = gen.generate(random.Random(SEED))
|
||||
_, injections = gen.SCENARIOS[scenario](base)
|
||||
manifest = gen.build_manifest(scenario, "driftwood.duckdb", f"driftwood-{scenario}.duckdb", injections)
|
||||
ok, detail = gen._check_manifest_well_formed(manifest)
|
||||
assert ok, detail
|
||||
|
||||
|
||||
def test_oracle_sanity_stopped_updates():
|
||||
ok, detail = gen._check_oracle_sanity()
|
||||
assert ok, detail
|
||||
|
||||
|
||||
def test_oracle_hand_computed_numbers():
|
||||
"""
|
||||
Hand-computed expectations (stopped_updates manifest: 1 real injection,
|
||||
3 clean entities checked — orders, web_events, fx_rates):
|
||||
|
||||
perfect = flags the real one correctly, calls all 3 clean ones clean
|
||||
-> recall 1/1, precision 1/1, false_alarm 0/3, attribution 1/1
|
||||
imperfect = misses the real one, false-alarms on 1 of the 3 clean ones
|
||||
-> recall 0/1, precision 0/1, false_alarm 1/3, attribution 0/0 -> 0.0
|
||||
"""
|
||||
base = gen.generate(random.Random(SEED))
|
||||
_, injections = gen.SCENARIOS["stopped_updates"](base)
|
||||
manifest = gen.build_manifest(
|
||||
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", injections
|
||||
)
|
||||
sets = gen._hand_authored_detection_sets(manifest)
|
||||
|
||||
perfect = gen.score_detections(manifest, sets["perfect"])
|
||||
assert perfect["recall"] == 1.0
|
||||
assert perfect["precision"] == 1.0
|
||||
assert perfect["false_alarm_rate"] == 0.0
|
||||
assert perfect["attribution_accuracy"] == 1.0
|
||||
|
||||
imperfect = gen.score_detections(manifest, sets["imperfect"])
|
||||
assert imperfect["recall"] == 0.0
|
||||
assert imperfect["precision"] == 0.0
|
||||
assert abs(imperfect["false_alarm_rate"] - (1 / 3)) < 1e-9
|
||||
assert imperfect["attribution_accuracy"] == 0.0
|
||||
|
||||
|
||||
def test_attribution_paraphrase_all_scenarios():
|
||||
ok, detail = gen._check_attribution_paraphrase()
|
||||
assert ok, detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
|
||||
def test_attribution_scores_per_injection(scenario):
|
||||
"""Per-injection attribution scores for every scenario (not just
|
||||
stopped_updates): the injection's own expected_cause must self-match at
|
||||
1.0, a realistic paraphrase (never the manifest's verbatim sentence,
|
||||
no ids/dates) must clear the 0.5 threshold, and an unrelated cause must
|
||||
fall below it — proving the fuzzy match works for real paraphrasing
|
||||
rather than only for a verbatim quote of the manifest."""
|
||||
base = gen.generate(random.Random(SEED))
|
||||
_, injections = gen.SCENARIOS[scenario](base)
|
||||
assert injections, f"{scenario} produced no injections"
|
||||
for inj in injections:
|
||||
verbatim_score = gen._attribution_score(inj["expected_cause"], inj)
|
||||
assert verbatim_score == 1.0, f"{inj['id']}: verbatim scored {verbatim_score}"
|
||||
|
||||
paraphrase = gen.PARAPHRASE_CAUSES[inj["id"]]
|
||||
paraphrase_score = gen._attribution_score(paraphrase, inj)
|
||||
assert paraphrase_score >= 0.5, f"{inj['id']}: paraphrase scored {paraphrase_score}"
|
||||
|
||||
unrelated_score = gen._attribution_score(gen.UNRELATED_CAUSE, inj)
|
||||
assert unrelated_score < 0.5, f"{inj['id']}: unrelated cause scored {unrelated_score}"
|
||||
|
||||
|
||||
def test_manifest_serializes_the_canonical_attribution_oracle():
|
||||
injection = {
|
||||
"id": "stopped_updates_subscription_snapshots",
|
||||
"kind": "stopped_updates",
|
||||
"entity": "subscription_snapshots",
|
||||
}
|
||||
manifest = gen.build_manifest(
|
||||
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", [injection]
|
||||
)
|
||||
serialized = manifest["injections"][0]
|
||||
|
||||
assert serialized["attribution_keywords"] == ["snapshots", "stopped", "subscription"]
|
||||
assert gen._attribution_score("stopped updates", serialized) == pytest.approx(1 / 3)
|
||||
assert not gen._cause_matches("stopped updates", serialized)
|
||||
|
||||
|
||||
def test_score_detections_rejects_duplicate_entities():
|
||||
base = gen.generate(random.Random(SEED))
|
||||
_, injections = gen.SCENARIOS["stopped_updates"](base)
|
||||
manifest = gen.build_manifest(
|
||||
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", injections
|
||||
)
|
||||
dup_detections = [
|
||||
{"entity": "orders", "verdict": "no_anomaly", "cause": ""},
|
||||
{"entity": "orders", "verdict": "anomaly", "cause": "duplicate row"},
|
||||
]
|
||||
with pytest.raises(ValueError):
|
||||
gen.score_detections(manifest, dup_detections)
|
||||
|
||||
|
||||
def test_stopped_updates_can_inject_from_a_clean_duckdb(tmp_path):
|
||||
base = tmp_path / "clean.duckdb"
|
||||
injected = tmp_path / "injected.duckdb"
|
||||
rows = [
|
||||
(date(2026, 3, 31), 1, Decimal("10.00"), "active"),
|
||||
(date(2026, 4, 30), 1, Decimal("10.00"), "active"),
|
||||
(date(2026, 5, 31), 1, Decimal("10.00"), "active"),
|
||||
(date(2026, 6, 30), 1, Decimal("10.00"), "active"),
|
||||
]
|
||||
con = gen.duckdb.connect(str(base))
|
||||
con.execute(
|
||||
"CREATE TABLE subscription_snapshots "
|
||||
"(snapshot_date DATE, subscription_id INTEGER, mrr_amount DECIMAL(12,2), status VARCHAR)"
|
||||
)
|
||||
con.executemany(gen.INSERT_SQL["subscription_snapshots"], rows)
|
||||
con.close()
|
||||
|
||||
gen.run_injection_from_base("stopped_updates", str(base), str(injected))
|
||||
|
||||
clean_con = gen.duckdb.connect(str(base), read_only=True)
|
||||
injected_con = gen.duckdb.connect(str(injected), read_only=True)
|
||||
assert clean_con.execute("SELECT max(snapshot_date) FROM subscription_snapshots").fetchone()[0] == gen.TODAY
|
||||
assert injected_con.execute("SELECT max(snapshot_date) FROM subscription_snapshots").fetchone()[0] == date(2026, 3, 31)
|
||||
clean_con.close()
|
||||
injected_con.close()
|
||||
|
||||
manifest = gen.yaml.safe_load(injected.with_suffix(".manifest.yaml").read_text())
|
||||
assert manifest["base"] == "clean.duckdb"
|
||||
assert manifest["scenario"] == "stopped_updates"
|
||||
assert manifest["injections"][0]["expected_severity"] == "critical"
|
||||
|
||||
|
||||
def test_injection_from_base_refuses_to_overwrite_the_fixture(tmp_path):
|
||||
base = tmp_path / "clean.duckdb"
|
||||
base.write_bytes(b"not opened because paths are rejected first")
|
||||
with pytest.raises(ValueError, match="different files"):
|
||||
gen.run_injection_from_base("stopped_updates", str(base), str(base))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"] + sys.argv[1:]))
|
||||
@@ -0,0 +1,7 @@
|
||||
schema_version: 5
|
||||
name: driftwood
|
||||
version: '1.0'
|
||||
catalog: wren
|
||||
schema: public
|
||||
data_source: duckdb
|
||||
profile: driftwood
|
||||
@@ -0,0 +1,14 @@
|
||||
# Jaffle Wren eval fixture
|
||||
|
||||
This directory is the authored Wren project used by the accuracy eval. Its raw models, views,
|
||||
relationships, cubes, instructions, DuckDB fixture, and `wren_project.yml` are checked in.
|
||||
`target/mdl.json` is generated by `wren context build` and is not committed.
|
||||
|
||||
`wren_project.yml` is authored project metadata. It deliberately does not name a Wren CLI connection
|
||||
`profile`: named profiles live in each user's `~/.wren/profiles.yml` and are not portable project
|
||||
metadata. Use `eval/build-jaffle-context.sh` to compile this fixture; the helper builds a temporary
|
||||
copy so any CLI normalization stays outside the tracked project.
|
||||
|
||||
The manifest's `catalog: wren` and `schema: public` identify this MDL project inside Wren Engine.
|
||||
They are not the DuckDB database's catalog or schema. Database catalog and schema values belong in
|
||||
each model's `table_reference`.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Declared analytical cube over the orders mart. Added in Phase 2 so the semantic layer carries
|
||||
# real, named metrics whose additivity is *decidable* — which is what lets `metric_additive` be
|
||||
# evaluated at compile time (a declared additive measure → pass; a non-additive one → fail) rather
|
||||
# than being unanswerable, as it is on a cube-less project.
|
||||
- name: revenue
|
||||
base_object: orders
|
||||
measures:
|
||||
# Additive: a sum-of-parts equals the whole → safe to decompose along dimensions.
|
||||
- { name: total_revenue, expression: "SUM(amount)", type: DOUBLE }
|
||||
# Non-additive: the average of parts is not the average of the whole → decomposition misleads.
|
||||
- { name: avg_order_value, expression: "AVG(amount)", type: DOUBLE }
|
||||
dimensions:
|
||||
- { name: status, expression: "status", type: TEXT }
|
||||
time_dimensions:
|
||||
- { name: order_date, expression: "order_date", type: DATE }
|
||||
@@ -0,0 +1,3 @@
|
||||
# User Instructions
|
||||
|
||||
Add custom rules or guidelines for LLM-based query generation here.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
name: customers
|
||||
properties:
|
||||
description: "Final customers mart. One row per customer, enriched with order history aggregates (first order date, most recent order date, total order count, lifetime value)."
|
||||
table_reference:
|
||||
catalog: jaffle_shop
|
||||
schema: main
|
||||
table: customers
|
||||
primary_key: customer_id
|
||||
columns:
|
||||
- name: customer_id
|
||||
type: INT
|
||||
not_null: true
|
||||
properties:
|
||||
description: "Unique customer identifier."
|
||||
- name: first_name
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Customer's first name."
|
||||
- name: last_name
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Customer's last name."
|
||||
- name: first_order
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Date of the customer's first order."
|
||||
- name: most_recent_order
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Date of the customer's most recent order."
|
||||
- name: number_of_orders
|
||||
type: BIGINT
|
||||
properties:
|
||||
description: "Total number of orders placed by this customer."
|
||||
- name: customer_lifetime_value
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Sum of all completed order amounts for this customer."
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Example model — replace with your actual table
|
||||
name: example
|
||||
# table_reference points to the ACTUAL database table location
|
||||
table_reference:
|
||||
catalog: "" # your database catalog (empty if N/A)
|
||||
schema: public # your database schema
|
||||
table: example # your database table name
|
||||
columns:
|
||||
- name: id
|
||||
type: INTEGER
|
||||
is_calculated: false
|
||||
not_null: true
|
||||
is_primary_key: true
|
||||
properties: {}
|
||||
- name: name
|
||||
type: VARCHAR
|
||||
is_calculated: false
|
||||
not_null: false
|
||||
properties: {}
|
||||
primary_key: id
|
||||
cached: false
|
||||
properties: {}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
name: orders
|
||||
properties:
|
||||
description: "Final orders mart. One row per order, with payment amounts broken out by method (credit card, coupon, bank transfer, gift card) plus the total order amount."
|
||||
table_reference:
|
||||
catalog: jaffle_shop
|
||||
schema: main
|
||||
table: orders
|
||||
primary_key: order_id
|
||||
columns:
|
||||
- name: order_id
|
||||
type: INT
|
||||
not_null: true
|
||||
properties:
|
||||
description: "Unique order identifier."
|
||||
- name: customer_id
|
||||
type: INT
|
||||
properties:
|
||||
description: "Foreign key to customers.customer_id."
|
||||
- name: order_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Date the order was placed."
|
||||
- name: status
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Order status: placed, shipped, completed, return_pending, or returned."
|
||||
- name: credit_card_amount
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Amount paid by credit card for this order."
|
||||
- name: coupon_amount
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Amount paid with coupons for this order."
|
||||
- name: bank_transfer_amount
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Amount paid by bank transfer for this order."
|
||||
- name: gift_card_amount
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Amount paid with gift cards for this order."
|
||||
- name: amount
|
||||
type: DOUBLE
|
||||
properties:
|
||||
description: "Total order amount (sum of all payment methods)."
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
name: raw_customers
|
||||
properties:
|
||||
description: "Raw source customer records as loaded from the source system. Use the customers mart model for analytics."
|
||||
table_reference:
|
||||
catalog: jaffle_shop
|
||||
schema: main
|
||||
table: raw_customers
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INT
|
||||
not_null: true
|
||||
properties:
|
||||
description: "Source system customer ID."
|
||||
- name: first_name
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Customer's first name."
|
||||
- name: last_name
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Customer's last name."
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
name: raw_orders
|
||||
properties:
|
||||
description: "Raw source order records as loaded from the source system. Use the orders mart model for analytics."
|
||||
table_reference:
|
||||
catalog: jaffle_shop
|
||||
schema: main
|
||||
table: raw_orders
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INT
|
||||
not_null: true
|
||||
properties:
|
||||
description: "Source system order ID."
|
||||
- name: user_id
|
||||
type: INT
|
||||
properties:
|
||||
description: "Foreign key to raw_customers.id."
|
||||
- name: order_date
|
||||
type: DATE
|
||||
properties:
|
||||
description: "Date the order was placed."
|
||||
- name: status
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Order status from source system."
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
name: raw_payments
|
||||
properties:
|
||||
description: "Raw source payment records. Each row is a single payment transaction; one order can have multiple payments via different methods."
|
||||
table_reference:
|
||||
catalog: jaffle_shop
|
||||
schema: main
|
||||
table: raw_payments
|
||||
primary_key: id
|
||||
columns:
|
||||
- name: id
|
||||
type: INT
|
||||
not_null: true
|
||||
properties:
|
||||
description: "Unique payment transaction ID."
|
||||
- name: order_id
|
||||
type: INT
|
||||
properties:
|
||||
description: "Foreign key to raw_orders.id."
|
||||
- name: payment_method
|
||||
type: TEXT
|
||||
properties:
|
||||
description: "Payment method used: credit_card, coupon, bank_transfer, or gift_card."
|
||||
- name: amount
|
||||
type: INT
|
||||
properties:
|
||||
description: "Payment amount in cents."
|
||||
@@ -0,0 +1,21 @@
|
||||
relationships:
|
||||
- name: orders_customers
|
||||
models:
|
||||
- orders
|
||||
- customers
|
||||
join_type: many_to_one
|
||||
condition: "orders.customer_id = customers.customer_id"
|
||||
|
||||
- name: raw_orders_raw_customers
|
||||
models:
|
||||
- raw_orders
|
||||
- raw_customers
|
||||
join_type: many_to_one
|
||||
condition: "raw_orders.user_id = raw_customers.id"
|
||||
|
||||
- name: raw_payments_raw_orders
|
||||
models:
|
||||
- raw_payments
|
||||
- raw_orders
|
||||
join_type: many_to_one
|
||||
condition: "raw_payments.order_id = raw_orders.id"
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# Example view — replace with your actual view
|
||||
name: example_view
|
||||
description: "An example view"
|
||||
properties: {}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
statement: >
|
||||
SELECT * FROM example LIMIT 100
|
||||
@@ -0,0 +1,11 @@
|
||||
schema_version: 2
|
||||
name: jaffle_shop
|
||||
version: "1.0"
|
||||
|
||||
# Wren Engine namespace (NOT your database's catalog/schema).
|
||||
# These identify this MDL project within the engine.
|
||||
# Your database's actual catalog/schema goes in each model's table_reference.
|
||||
catalog: wren
|
||||
schema: public
|
||||
|
||||
data_source: duckdb
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Integration tests over the real `examples/jaffle-wren` project. It declares a `revenue` cube
|
||||
//! (Phase 2), so `metric_additive` is answerable — `total_revenue` (SUM) is additive and
|
||||
//! `avg_order_value` (AVG) is not. Existence predicates also hold via plain model columns. The
|
||||
//! cube-*less* case (where `metric_additive` is unanswerable) is covered by a synthetic manifest in
|
||||
//! the crate's unit tests.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use warble::{Additivity, ContextLoader, Severity};
|
||||
use wren_context_loader::{read_project_dir, MdlContext};
|
||||
|
||||
fn jaffle_wren() -> MdlContext {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/jaffle-wren");
|
||||
let sources = read_project_dir(&dir)
|
||||
.expect("read jaffle-wren")
|
||||
.expect("jaffle-wren is a wren project");
|
||||
MdlContext::try_from_sources(&sources).expect("jaffle-wren assembles into a valid manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_projects_models() {
|
||||
let ctx = jaffle_wren();
|
||||
assert!(ctx.is_parseable());
|
||||
|
||||
// Every jaffle model is projected.
|
||||
let model_names: Vec<&str> = ctx.models().iter().map(|m| m.name.as_str()).collect();
|
||||
for expected in [
|
||||
"customers",
|
||||
"orders",
|
||||
"raw_customers",
|
||||
"raw_orders",
|
||||
"raw_payments",
|
||||
] {
|
||||
assert!(
|
||||
model_names.contains(&expected),
|
||||
"missing model {expected}: {model_names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// orders has a DATE column (order_date) → has_timestamp.
|
||||
let orders = ctx.model("orders").expect("orders model");
|
||||
assert!(orders.has_timestamp, "orders has a DATE column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existence_predicates_hold_via_columns() {
|
||||
let ctx = jaffle_wren();
|
||||
// Numeric columns (amount, customer_lifetime_value, …) → has_metric holds even with no cube.
|
||||
assert!(
|
||||
!ctx.metrics().is_empty(),
|
||||
"numeric columns are queryable metrics"
|
||||
);
|
||||
// Textual columns (status, first_name, …) → groupable dimensions.
|
||||
assert!(!ctx.dimensions().is_empty());
|
||||
// DATE columns (order_date, first_order, …) → time dimensions.
|
||||
assert!(!ctx.time_dimensions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_additive_answerable_via_revenue_cube() {
|
||||
let ctx = jaffle_wren();
|
||||
// The revenue cube declares measures ⇒ additivity is expressible ⇒ answerable.
|
||||
assert!(
|
||||
ctx.can_answer("metric_additive"),
|
||||
"the revenue cube makes metric_additive answerable"
|
||||
);
|
||||
// Additivity is inferred from each measure's aggregation.
|
||||
assert_eq!(
|
||||
ctx.metric_additivity("total_revenue"),
|
||||
Some(Additivity::Additive),
|
||||
"total_revenue = SUM(amount) is additive"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.metric_additivity("avg_order_value"),
|
||||
Some(Additivity::NonAdditive),
|
||||
"avg_order_value = AVG(amount) is non-additive"
|
||||
);
|
||||
// Both declared (cube) and implicit (column) metrics are present.
|
||||
assert!(ctx.metrics().iter().any(|m| m.declared));
|
||||
assert!(ctx.metrics().iter().any(|m| !m.declared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineage_is_resolvable() {
|
||||
let ctx = jaffle_wren();
|
||||
let lineage = ctx.lineage();
|
||||
assert!(
|
||||
lineage.is_resolvable(),
|
||||
"no dangling references in jaffle-wren lineage"
|
||||
);
|
||||
// Relationships connect real models: orders → rel:orders_customers is an edge.
|
||||
assert!(lineage.contains("model:orders"));
|
||||
assert!(lineage.contains("rel:orders_customers"));
|
||||
assert!(lineage
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.from == "model:orders" && e.to == "rel:orders_customers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blast_radius_of_orders_reaches_the_revenue_cube_and_is_semantic() {
|
||||
let ctx = jaffle_wren();
|
||||
let radius = ctx.lineage().blast_radius("model:orders");
|
||||
// The revenue cube sits on orders, so changing orders reaches the cube + its measures.
|
||||
assert!(radius.downstream.contains(&"cube:revenue".to_string()));
|
||||
assert!(radius
|
||||
.downstream
|
||||
.contains(&"metric:revenue.total_revenue".to_string()));
|
||||
// A downstream metric ⇒ the worst impact is a silent number shift ⇒ semantic.
|
||||
assert_eq!(radius.severity, Severity::Semantic);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::fs;
|
||||
use wren_context_loader::read_knowledge_rules;
|
||||
|
||||
#[test]
|
||||
fn knowledge_rules_are_sorted_trimmed_and_followed_by_legacy_content() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let rules = project.path().join("knowledge/rules");
|
||||
fs::create_dir_all(&rules).unwrap();
|
||||
fs::write(rules.join("z-last.md"), "\n# Z\r\n\r\nlast\n").unwrap();
|
||||
fs::write(rules.join("a-first.md"), "# A\n\nfirst\n").unwrap();
|
||||
fs::write(rules.join("empty.md"), " \n").unwrap();
|
||||
fs::write(project.path().join("instructions.md"), "\n# Legacy\n").unwrap();
|
||||
|
||||
let loaded = read_knowledge_rules(project.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.content,
|
||||
"# A\n\nfirst\n\n# Z\r\n\r\nlast\n\n# Legacy"
|
||||
);
|
||||
assert!(loaded.used_legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rules_are_an_explicit_empty_payload() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let loaded = read_knowledge_rules(project.path()).unwrap();
|
||||
assert_eq!(loaded.content, "");
|
||||
assert!(!loaded.used_legacy);
|
||||
}
|
||||
Reference in New Issue
Block a user