Skip to main content

vantage_vista/
spec.rs

1//! YAML-facing schema for vista.
2//!
3//! A driver factory parses a `VistaSpec<T, C, R>` from YAML and lowers it
4//! into a `Vista` via `VistaFactory::build_from_spec`. The three type
5//! parameters carry driver-specific YAML blocks at the table, column, and
6//! reference level. Each parameter defaults to [`NoExtras`] for drivers
7//! that don't need any.
8
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize, de::DeserializeOwned};
11use vantage_core::Result;
12
13use crate::column::{Column, ColumnFlag};
14use crate::reference::{JoinKey, ReferenceKind};
15
16mod lowering;
17pub use lowering::{SpecResolver, resolve_base_spec};
18
19/// Empty extras placeholder. Serializes as an absent key.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22#[non_exhaustive]
23pub struct NoExtras {}
24
25/// The YAML schema a vista is built from.
26///
27/// `T` carries the driver's table-level block (e.g. `csv: { path }`).
28/// `C` carries each column's driver block. `R` carries each reference's
29/// driver block. The outer struct cannot use `deny_unknown_fields` because
30/// of `#[serde(flatten)]`; the driver-specific extras struct should set
31/// `deny_unknown_fields` itself to catch typos in driver blocks.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(bound(
34    serialize = "T: Serialize, C: Serialize, R: Serialize",
35    deserialize = "T: DeserializeOwned + Default, C: DeserializeOwned + Default, R: DeserializeOwned + Default"
36))]
37#[non_exhaustive]
38pub struct VistaSpec<T = NoExtras, C = NoExtras, R = NoExtras> {
39    pub name: String,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub datasource: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub id_column: Option<String>,
44    pub columns: IndexMap<String, ColumnSpec<C>>,
45    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
46    pub references: IndexMap<String, ReferenceSpec<R>>,
47    /// Contained (embedded-in-row) relations, keyed by relation name. Each
48    /// becomes a reference of a contained kind.
49    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
50    pub contained: IndexMap<String, ContainedYaml<C>>,
51    #[serde(flatten, default)]
52    pub driver: T,
53}
54
55impl<T: Default, C, R> VistaSpec<T, C, R> {
56    /// A spec named `name` that declares nothing else: no columns,
57    /// references or contained relations, and the driver's default block.
58    pub fn new(name: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            datasource: None,
62            id_column: None,
63            columns: IndexMap::new(),
64            references: IndexMap::new(),
65            contained: IndexMap::new(),
66            driver: T::default(),
67        }
68    }
69}
70
71/// YAML shape of a contained relation — embedded records stored in a column of
72/// the parent row. Mirrors [`ReferenceSpec`] but carries the contained
73/// record's own column schema (built via the driver's `build_column`) instead
74/// of a foreign key.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(
77    deny_unknown_fields,
78    bound(
79        serialize = "C: Serialize",
80        deserialize = "C: DeserializeOwned + Default"
81    )
82)]
83#[non_exhaustive]
84pub struct ContainedYaml<C = NoExtras> {
85    /// Column on the parent row holding the embedded object (one) or array (many).
86    pub host_column: String,
87    /// `contains_one` (the default) or `contains_many`.
88    #[serde(default = "contains_one")]
89    pub kind: ReferenceKind,
90    /// Field used as each contained record's id (`None` → positional index).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub id_column: Option<String>,
93    /// The contained record's columns.
94    pub columns: IndexMap<String, ColumnSpec<C>>,
95}
96
97fn contains_one() -> ReferenceKind {
98    ReferenceKind::ContainsOne
99}
100
101fn has_one() -> ReferenceKind {
102    ReferenceKind::HasOne
103}
104
105/// Per-column metadata in a `VistaSpec`.
106///
107/// `flags` are snake_case names; a name vista doesn't know is kept as
108/// [`ColumnFlag::Other`] for the layer that owns the YAML schema to accept
109/// or reject.
110/// `references` holds the sugar form (`references: products`) when the
111/// foreign key is the column itself; full reference declarations live in
112/// the parent `VistaSpec::references` map.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(bound(
115    serialize = "C: Serialize",
116    deserialize = "C: DeserializeOwned + Default"
117))]
118#[non_exhaustive]
119pub struct ColumnSpec<C = NoExtras> {
120    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
121    pub col_type: Option<String>,
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub flags: Vec<ColumnFlag>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub references: Option<ReferenceSugar>,
126    /// Rhai script for a *lazy* computed column. Runs in Rust on each
127    /// returned record — the record as built so far is exposed as `row`,
128    /// and the script's final expression becomes this column's value.
129    /// Lazy columns apply in declaration order, so a later one sees the
130    /// values earlier ones produced. Lowered via
131    /// [`lazy_column`](Self::lazy_column) onto a Vista computed column;
132    /// never part of the backend query.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub lazy: Option<String>,
135    /// Rhai script for a *server-side* computed column. Evaluated once at
136    /// build time with the driver's expression vocabulary
137    /// (`surreal::ident(...)`, operators, `surreal::expr("raw")`, …) into a
138    /// query expression, then lowered via `Table::with_expression` — the
139    /// backend projects it as `(<expr>) AS <column>`. Unlike `lazy:` it
140    /// participates in the query, so it can traverse record links or call
141    /// backend functions. Computed columns are read-only.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub expr: Option<String>,
144    #[serde(flatten, default)]
145    pub driver: C,
146}
147
148impl<C: Default> ColumnSpec<C> {
149    pub fn new() -> Self {
150        Self {
151            col_type: None,
152            flags: Vec::new(),
153            references: None,
154            lazy: None,
155            expr: None,
156            driver: C::default(),
157        }
158    }
159
160    pub fn with_type(mut self, ty: impl Into<String>) -> Self {
161        self.col_type = Some(ty.into());
162        self
163    }
164
165    pub fn with_flag(mut self, flag: impl Into<ColumnFlag>) -> Self {
166        self.flags.push(ColumnFlag::from(flag.into().as_str()));
167        self
168    }
169
170    pub fn with_lazy(mut self, script: impl Into<String>) -> Self {
171        self.lazy = Some(script.into());
172        self
173    }
174
175    pub fn with_expr(mut self, script: impl Into<String>) -> Self {
176        self.expr = Some(script.into());
177        self
178    }
179}
180
181impl<C> ColumnSpec<C> {
182    /// The Vista column for a `lazy:` column — its type and flags, computed
183    /// by its script (see `Column::with_expression`) — or `None` for a
184    /// stored column. Errors when the script doesn't compile, or when this
185    /// crate was built without the `rhai` feature.
186    pub fn lazy_column(&self, name: &str) -> Result<Option<Column>> {
187        let Some(code) = &self.lazy else {
188            return Ok(None);
189        };
190        let typed = match &self.col_type {
191            Some(ty) => Column::new(name, ty),
192            None => Column::untyped(name),
193        };
194        let column = self.flags.iter().cloned().fold(typed, Column::with_flag);
195        computed(column, code).map(Some)
196    }
197}
198
199#[cfg(feature = "rhai")]
200fn computed(column: Column, code: &str) -> Result<Column> {
201    column.with_expression(code)
202}
203
204#[cfg(not(feature = "rhai"))]
205fn computed(column: Column, _code: &str) -> Result<Column> {
206    Err(vantage_core::error!(
207        "Column declares a `lazy:` script but vantage-vista was built without the `rhai` feature",
208        column = column.name
209    ))
210}
211
212impl<C: Default> Default for ColumnSpec<C> {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// Top-level reference declaration in a `VistaSpec`.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(bound(
221    serialize = "R: Serialize",
222    deserialize = "R: DeserializeOwned + Default"
223))]
224#[non_exhaustive]
225pub struct ReferenceSpec<R = NoExtras> {
226    pub table: String,
227    /// `has_one` or `has_many`; defaults to `has_one`.
228    #[serde(default = "has_one")]
229    pub kind: ReferenceKind,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub foreign_key: Option<String>,
232    /// Extra join keys for row-supplied traversal. When non-empty these
233    /// fully describe the join: each maps a parent (locked-row) column to
234    /// a child column, and the child is constrained by all of them. This
235    /// lets a child be narrowed by more than one parent field (e.g. a
236    /// deployment narrowed by both `product_id` and `version_id`). When
237    /// empty, the single `foreign_key` sugar applies.
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub keys: Vec<JoinKey>,
240    #[serde(flatten, default)]
241    pub driver: R,
242}
243
244/// Sugar form for inline column references.
245///
246/// `references: products` deserializes as `Sugar("products")`, equivalent
247/// to a `has_one` reference with `foreign_key` defaulting to the column
248/// name. Anything richer uses the full form.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(untagged)]
251#[non_exhaustive]
252pub enum ReferenceSugar {
253    Sugar(String),
254    Full(ReferenceSpec<NoExtras>),
255}