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};
11
12use crate::reference::ReferenceKind;
13
14/// Empty extras placeholder. Serializes as an absent key.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct NoExtras {}
18
19/// The YAML schema a vista is built from.
20///
21/// `T` carries the driver's table-level block (e.g. `csv: { path }`).
22/// `C` carries each column's driver block. `R` carries each reference's
23/// driver block. The outer struct cannot use `deny_unknown_fields` because
24/// of `#[serde(flatten)]`; the driver-specific extras struct should set
25/// `deny_unknown_fields` itself to catch typos in driver blocks.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(bound(
28    serialize = "T: Serialize, C: Serialize, R: Serialize",
29    deserialize = "T: DeserializeOwned + Default, C: DeserializeOwned + Default, R: DeserializeOwned + Default"
30))]
31pub struct VistaSpec<T = NoExtras, C = NoExtras, R = NoExtras> {
32    pub name: String,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub datasource: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub id_column: Option<String>,
37    pub columns: IndexMap<String, ColumnSpec<C>>,
38    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
39    pub references: IndexMap<String, ReferenceSpec<R>>,
40    #[serde(flatten, default)]
41    pub driver: T,
42}
43
44/// Per-column metadata in a `VistaSpec`.
45///
46/// `flags` is open and unvalidated — the constants in [`crate::flags`]
47/// name the values understood by vista's own accessors.
48/// `references` holds the sugar form (`references: products`) when the
49/// foreign key is the column itself; full reference declarations live in
50/// the parent `VistaSpec::references` map.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(bound(
53    serialize = "C: Serialize",
54    deserialize = "C: DeserializeOwned + Default"
55))]
56pub struct ColumnSpec<C = NoExtras> {
57    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
58    pub col_type: Option<String>,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub flags: Vec<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub references: Option<ReferenceSugar>,
63    #[serde(flatten, default)]
64    pub driver: C,
65}
66
67impl<C: Default> ColumnSpec<C> {
68    pub fn new() -> Self {
69        Self {
70            col_type: None,
71            flags: Vec::new(),
72            references: None,
73            driver: C::default(),
74        }
75    }
76
77    pub fn with_type(mut self, ty: impl Into<String>) -> Self {
78        self.col_type = Some(ty.into());
79        self
80    }
81
82    pub fn with_flag(mut self, flag: impl Into<String>) -> Self {
83        self.flags.push(flag.into());
84        self
85    }
86}
87
88impl<C: Default> Default for ColumnSpec<C> {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94/// Top-level reference declaration in a `VistaSpec`.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(bound(
97    serialize = "R: Serialize",
98    deserialize = "R: DeserializeOwned + Default"
99))]
100pub struct ReferenceSpec<R = NoExtras> {
101    pub table: String,
102    #[serde(default)]
103    pub kind: ReferenceKind,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub foreign_key: Option<String>,
106    #[serde(flatten, default)]
107    pub driver: R,
108}
109
110/// Sugar form for inline column references.
111///
112/// `references: products` deserializes as `Sugar("products")`, equivalent
113/// to a `has_one` reference with `foreign_key` defaulting to the column
114/// name. Anything richer uses the full form.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(untagged)]
117pub enum ReferenceSugar {
118    Sugar(String),
119    Full(ReferenceSpec<NoExtras>),
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use serde::{Deserialize, Serialize};
126
127    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
128    #[serde(deny_unknown_fields)]
129    struct DummyTable {
130        dummy: DummyTableBlock,
131    }
132
133    #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
134    #[serde(deny_unknown_fields)]
135    struct DummyTableBlock {
136        path: String,
137    }
138
139    type DummySpec = VistaSpec<DummyTable, NoExtras, NoExtras>;
140
141    #[test]
142    fn parses_minimal_spec() {
143        let yaml = r#"
144name: clients
145columns:
146  id:
147    type: int
148    flags: [id]
149  name:
150    type: string
151    flags: [title, searchable]
152dummy:
153  path: data/clients.csv
154"#;
155        let spec: DummySpec = serde_yaml_ng::from_str(yaml).unwrap();
156        assert_eq!(spec.name, "clients");
157        assert_eq!(spec.columns.len(), 2);
158        assert_eq!(spec.columns["id"].col_type.as_deref(), Some("int"));
159        assert_eq!(spec.columns["name"].flags, vec!["title", "searchable"]);
160        assert_eq!(spec.driver.dummy.path, "data/clients.csv");
161    }
162
163    #[test]
164    fn rejects_unknown_driver_field() {
165        let yaml = r#"
166name: clients
167columns:
168  id: { type: int }
169dummy:
170  path: x
171  bogus: 1
172"#;
173        let err = serde_yaml_ng::from_str::<DummySpec>(yaml).unwrap_err();
174        assert!(
175            err.to_string().contains("bogus") || err.to_string().contains("unknown"),
176            "expected typo-detecting error, got: {err}"
177        );
178    }
179
180    #[test]
181    fn reference_sugar_round_trip() {
182        let yaml = r#"
183name: clients
184columns:
185  shop_id:
186    type: int
187    references: shops
188dummy:
189  path: x
190"#;
191        let spec: DummySpec = serde_yaml_ng::from_str(yaml).unwrap();
192        match spec.columns["shop_id"].references.as_ref().unwrap() {
193            ReferenceSugar::Sugar(s) => assert_eq!(s, "shops"),
194            other => panic!("expected sugar, got {other:?}"),
195        }
196    }
197
198    #[test]
199    fn full_reference_form() {
200        let yaml = r#"
201name: orders
202columns:
203  user_id:
204    type: int
205    references:
206      table: users
207      kind: has_one
208      foreign_key: user_id
209dummy:
210  path: x
211"#;
212        let spec: DummySpec = serde_yaml_ng::from_str(yaml).unwrap();
213        match spec.columns["user_id"].references.as_ref().unwrap() {
214            ReferenceSugar::Full(r) => {
215                assert_eq!(r.table, "users");
216                assert_eq!(r.kind, ReferenceKind::HasOne);
217                assert_eq!(r.foreign_key.as_deref(), Some("user_id"));
218            }
219            other => panic!("expected full, got {other:?}"),
220        }
221    }
222}