Skip to main content

warble_mdl_context/
raw_source.rs

1//! Warble ContextLoader adapter #2 — the raw-source adapter (constitutive family).
2//!
3//! A CONSTITUTIVE component's bound Context is a **raw source** with no MDL yet — the component's
4//! output *is* the MDL (or a knowledge enrichment). This adapter answers the two raw-shape probes
5//! (`source_introspectable` / `raw_docs_readable`) that [`crate::MdlContext`] leaves `None`, which
6//! is the inversion the constitutive family depends on: a bound raw source is *parseable* (the
7//! coarse floor passes) even though it carries no metrics/dimensions/models/lineage at all.
8//!
9//! Pipeline mirrors [`crate::project`]: a host-I/O reader ([`read_raw_dir`], native-only) fills
10//! [`RawSources`] from disk; [`RawSourceContext::from_sources`] is the pure, WASM-friendly parse.
11
12use serde::Deserialize;
13
14use warble::{ContextLoader, DimensionInfo, LineageGraph, MetricInfo, ModelInfo};
15
16/// A raw source's `schema.json`: a source name + its tables. Deliberately permissive — the fixture
17/// carries extra keys (a top-level `description`, possibly per-column notes) that this adapter does
18/// not need, so no `deny_unknown_fields`.
19#[derive(Debug, Deserialize)]
20struct RawSchema {
21    #[allow(dead_code)]
22    source: String,
23    #[serde(default)]
24    tables: Vec<RawTable>,
25}
26
27#[derive(Debug, Deserialize)]
28struct RawTable {
29    #[allow(dead_code)]
30    name: String,
31    #[serde(default)]
32    columns: Vec<RawColumn>,
33}
34
35#[derive(Debug, Deserialize)]
36struct RawColumn {
37    #[allow(dead_code)]
38    name: String,
39    #[serde(rename = "type")]
40    #[allow(dead_code)]
41    col_type: String,
42}
43
44/// The raw file contents of a raw source, read by the host. `from_sources` operates purely over
45/// these bytes (WASM-friendly); the native [`read_raw_dir`] convenience fills them from disk.
46pub struct RawSources {
47    pub schema_json: String,
48    /// Whether `docs/` exists under the raw source dir and contains at least one file.
49    pub has_docs: bool,
50}
51
52/// A `ContextLoader` backed by a raw (pre-MDL) source. Carries no metrics/dimensions/models/lineage
53/// — that is what a CONSTITUTIVE component's output *produces* — but answers the two raw-shape
54/// probes that make `source_introspectable` / `raw_docs_readable` evaluable instead of unanswerable.
55pub struct RawSourceContext {
56    parseable: bool,
57    source_introspectable: Option<bool>,
58    raw_docs_readable: Option<bool>,
59    lineage: LineageGraph,
60}
61
62impl RawSourceContext {
63    /// Build from read raw-source sources. On a schema parse failure, returns
64    /// [`RawSourceContext::unparseable`] rather than an error, per the sans-IO probe model.
65    pub fn from_sources(sources: &RawSources) -> Self {
66        match serde_json::from_str::<RawSchema>(&sources.schema_json) {
67            Ok(schema) => {
68                let introspectable = schema.tables.iter().any(|t| !t.columns.is_empty());
69                RawSourceContext {
70                    parseable: true,
71                    source_introspectable: Some(introspectable),
72                    raw_docs_readable: Some(sources.has_docs),
73                    lineage: LineageGraph::default(),
74                }
75            }
76            Err(_) => Self::unparseable(),
77        }
78    }
79
80    /// A bound-but-broken raw source: `schema.json` failed to parse. `is_parseable()` is `false`,
81    /// so `warble::compile`'s coarse floor loud-fails before either precondition is even evaluated —
82    /// the probe values below are set to `None` (cannot answer over a source we couldn't read) to be
83    /// honest rather than guess a `Some(false)` we didn't actually establish.
84    pub fn unparseable() -> Self {
85        RawSourceContext {
86            parseable: false,
87            source_introspectable: None,
88            raw_docs_readable: None,
89            lineage: LineageGraph::default(),
90        }
91    }
92}
93
94impl ContextLoader for RawSourceContext {
95    fn is_parseable(&self) -> bool {
96        self.parseable
97    }
98    fn metrics(&self) -> &[MetricInfo] {
99        &[]
100    }
101    fn dimensions(&self) -> &[DimensionInfo] {
102        &[]
103    }
104    fn time_dimensions(&self) -> &[DimensionInfo] {
105        &[]
106    }
107    fn models(&self) -> &[ModelInfo] {
108        &[]
109    }
110    fn lineage(&self) -> &LineageGraph {
111        &self.lineage
112    }
113    fn source_introspectable(&self) -> Option<bool> {
114        self.source_introspectable
115    }
116    fn raw_docs_readable(&self) -> Option<bool> {
117        self.raw_docs_readable
118    }
119}
120
121// --- native host convenience ---------------------------------------------------------------------
122
123/// Read a raw-source directory into [`RawSources`] (native host only; the pure
124/// [`RawSourceContext::from_sources`] path is what a WASM host feeds directly). Returns `None` if
125/// the directory has no `schema.json` — i.e. it is not a raw source — so the caller can fall through
126/// to another adapter or an unparseable context.
127#[cfg(not(target_arch = "wasm32"))]
128pub fn read_raw_dir(dir: &std::path::Path) -> std::io::Result<Option<RawSources>> {
129    use std::fs;
130
131    let schema_path = dir.join("schema.json");
132    if !schema_path.is_file() {
133        return Ok(None);
134    }
135    let schema_json = fs::read_to_string(&schema_path)?;
136
137    let docs_dir = dir.join("docs");
138    let has_docs = docs_dir.is_dir()
139        && fs::read_dir(&docs_dir)?
140            .filter_map(Result::ok)
141            .any(|e| e.path().is_file());
142
143    Ok(Some(RawSources {
144        schema_json,
145        has_docs,
146    }))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn good_sources(has_docs: bool) -> RawSources {
154        RawSources {
155            schema_json: r#"{
156                "source": "synthetic_shop",
157                "description": "a raw source",
158                "tables": [
159                    { "name": "raw_orders", "columns": [
160                        { "name": "order_id", "type": "INTEGER" },
161                        { "name": "amount", "type": "DECIMAL" }
162                    ] }
163                ]
164            }"#
165            .to_string(),
166            has_docs,
167        }
168    }
169
170    #[test]
171    fn good_schema_is_introspectable_and_reflects_docs() {
172        let ctx = RawSourceContext::from_sources(&good_sources(true));
173        assert!(ctx.is_parseable());
174        assert_eq!(ctx.source_introspectable(), Some(true));
175        assert_eq!(ctx.raw_docs_readable(), Some(true));
176
177        let ctx_no_docs = RawSourceContext::from_sources(&good_sources(false));
178        assert_eq!(ctx_no_docs.raw_docs_readable(), Some(false));
179    }
180
181    #[test]
182    fn empty_tables_are_not_introspectable() {
183        let sources = RawSources {
184            schema_json: r#"{"source":"empty_src","tables":[]}"#.to_string(),
185            has_docs: false,
186        };
187        let ctx = RawSourceContext::from_sources(&sources);
188        assert!(ctx.is_parseable(), "an empty-but-valid schema still parses");
189        assert_eq!(ctx.source_introspectable(), Some(false));
190    }
191
192    #[test]
193    fn unparseable_schema_yields_unparseable_context() {
194        let sources = RawSources {
195            schema_json: "not json at all".to_string(),
196            has_docs: true,
197        };
198        let ctx = RawSourceContext::from_sources(&sources);
199        assert!(!ctx.is_parseable());
200        assert_eq!(ctx.source_introspectable(), None);
201        assert_eq!(ctx.raw_docs_readable(), None);
202    }
203
204    #[test]
205    fn raw_source_context_can_answer_raw_shape_predicates() {
206        let ctx = RawSourceContext::from_sources(&good_sources(true));
207        assert!(ctx.can_answer("source_introspectable"));
208        assert!(ctx.can_answer("raw_docs_readable"));
209    }
210
211    #[test]
212    fn mdl_context_cannot_answer_raw_shape_predicates() {
213        // An MDL-only adapter leaves the raw-shape probes at their trait defaults (`None`) — the
214        // inversion this adapter exists to fill in.
215        use crate::MdlContext;
216        use wren_core_base::mdl::manifest::Manifest;
217
218        let json = r#"{
219          "catalog":"wren","schema":"public",
220          "models":[],"relationships":[],"cubes":[],"views":[]
221        }"#;
222        let manifest: Manifest = serde_json::from_str(json).unwrap();
223        let ctx = MdlContext::from_manifest(&manifest);
224        assert_eq!(ctx.source_introspectable(), None);
225        assert_eq!(ctx.raw_docs_readable(), None);
226        assert!(!ctx.can_answer("source_introspectable"));
227        assert!(!ctx.can_answer("raw_docs_readable"));
228    }
229}