Skip to main content

vantage_vista/
contained.rs

1//! Contained relations — records embedded inside a column of the parent row.
2//!
3//! A `contains_one` relation surfaces a single embedded object (e.g. a
4//! product's `inventory`); `contains_many` surfaces an embedded array (e.g. an
5//! order's `lines`). Both are presented as a full sub-[`Vista`] — listable,
6//! gettable, insertable — backed by an in-memory [`ImTable`]. Writes are
7//! **eager**: every mutation re-serializes the whole collection and patches the
8//! parent row's host column through a `writeback` closure supplied by whoever
9//! built the sub-Vista.
10//!
11//! See [`build_contained_vista`] for construction and [`ContainedShell`] for the
12//! `TableShell` behaviour.
13
14use std::{future::Future, pin::Pin, sync::Arc};
15
16use async_trait::async_trait;
17use ciborium::Value as CborValue;
18use indexmap::IndexMap;
19use vantage_core::Result;
20use vantage_dataset::im::{ImDataSource, ImTable};
21use vantage_dataset::traits::{ReadableValueSet, WritableValueSet};
22use vantage_types::{EmptyEntity, Record};
23
24use crate::{
25    capabilities::VistaCapabilities,
26    column::Column,
27    metadata::VistaMetadata,
28    reference::{ContainedKind, ContainedSpec, Reference},
29    source::TableShell,
30    vista::Vista,
31};
32
33/// Persists a contained relation's collection back into the parent row.
34///
35/// Called after every write with the full re-serialized column value (a
36/// `CborValue::Map` for `contains_one`, a `CborValue::Array` for
37/// `contains_many`). The closure patches the parent record's host column —
38/// the persistence-specific part the host supplies at traversal time.
39pub type ContainedWriteback =
40    Arc<dyn Fn(CborValue) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> + Send + Sync>;
41
42/// Resolves a relation *of* a contained record (e.g. a line's `product`) back
43/// into the real table as a `Vista`. Supplied by the host at traversal time,
44/// capturing the contained table's own `with_one`/`with_many` registrations.
45pub type ContainedRefResolver =
46    Arc<dyn Fn(&str, &Record<CborValue>) -> Result<Vista> + Send + Sync>;
47
48/// `TableShell` over a contained relation's in-memory records.
49///
50/// Reads delegate to the seeded [`ImTable`]; writes apply to it and then flush
51/// the whole collection through [`ContainedWriteback`]. Holds no parent handle
52/// itself — the writeback closure captures whatever the host needs to patch the
53/// parent row.
54pub struct ContainedShell {
55    im: ImTable<EmptyEntity, CborValue>,
56    metadata: VistaMetadata,
57    kind: ContainedKind,
58    id_column: Option<String>,
59    capabilities: VistaCapabilities,
60    writeback: ContainedWriteback,
61    ref_resolver: Option<ContainedRefResolver>,
62}
63
64/// Build a sub-[`Vista`] over the records embedded in `host_value`.
65///
66/// `host_value` is the parent row's host column: a `CborValue::Map` for
67/// `contains_one`, a `CborValue::Array` of maps for `contains_many`, or `None`
68/// when the column is absent (an empty contained set). `writeback` is invoked
69/// after every mutation with the re-serialized collection.
70pub fn build_contained_vista(
71    spec: &ContainedSpec,
72    host_value: Option<&CborValue>,
73    writeback: ContainedWriteback,
74    ref_resolver: Option<ContainedRefResolver>,
75) -> Result<Vista> {
76    let ds = ImDataSource::<CborValue>::new();
77    let im = ImTable::<EmptyEntity, CborValue>::new(&ds, &spec.name);
78    im.seed(seed_records(spec, host_value));
79
80    let mut metadata = VistaMetadata::new();
81    for column in &spec.columns {
82        metadata = metadata.with_column(column.clone());
83    }
84    if let Some(id) = &spec.id_column {
85        metadata = metadata.with_id_column(id.clone());
86    }
87
88    let shell = ContainedShell {
89        im,
90        metadata,
91        kind: spec.kind,
92        id_column: spec.id_column.clone(),
93        capabilities: VistaCapabilities {
94            can_count: true,
95            can_insert: true,
96            can_update: true,
97            can_delete: true,
98            ..VistaCapabilities::default()
99        },
100        writeback,
101        ref_resolver,
102    };
103    Ok(Vista::new(spec.name.clone(), Box::new(shell)))
104}
105
106/// Materialize the parent column into ordered `(id, record)` rows.
107fn seed_records(
108    spec: &ContainedSpec,
109    host_value: Option<&CborValue>,
110) -> IndexMap<String, Record<CborValue>> {
111    let mut rows = IndexMap::new();
112    match spec.kind {
113        ContainedKind::ContainsOne => {
114            if let Some(record) = host_value.and_then(map_to_record) {
115                rows.insert(ONE_ID.to_string(), record);
116            }
117        }
118        ContainedKind::ContainsMany => {
119            if let Some(CborValue::Array(items)) = host_value {
120                for (idx, item) in items.iter().enumerate() {
121                    if let Some(record) = map_to_record(item) {
122                        rows.insert(row_id(spec, &record, idx), record);
123                    }
124                }
125            }
126        }
127    }
128    rows
129}
130
131impl ContainedShell {
132    /// Re-serialize the in-memory collection and persist it to the parent
133    /// column. Runs after every mutation.
134    async fn flush(&self) -> Result<()> {
135        let rows = self.im.list_values().await?;
136        let value = match self.kind {
137            ContainedKind::ContainsOne => rows
138                .into_values()
139                .next()
140                .map(CborValue::from)
141                .unwrap_or_else(|| CborValue::Map(Vec::new())),
142            ContainedKind::ContainsMany => {
143                CborValue::Array(rows.into_values().map(CborValue::from).collect())
144            }
145        };
146        (self.writeback)(value).await
147    }
148
149    /// The id a record should take on insert: its declared id-column value if
150    /// present, the fixed relation id for `contains_one`, otherwise the next
151    /// positional index.
152    async fn next_id(&self, record: &Record<CborValue>) -> Result<String> {
153        if let Some(id) = self.id_column.as_deref().and_then(|c| record.get(c)) {
154            return Ok(cbor_scalar_string(id));
155        }
156        match self.kind {
157            ContainedKind::ContainsOne => Ok(ONE_ID.to_string()),
158            ContainedKind::ContainsMany => Ok(self.im.list_values().await?.len().to_string()),
159        }
160    }
161}
162
163#[async_trait]
164impl TableShell for ContainedShell {
165    fn columns(&self) -> &IndexMap<String, Column> {
166        &self.metadata.columns
167    }
168
169    fn references(&self) -> &IndexMap<String, Reference> {
170        &self.metadata.references
171    }
172
173    fn id_column(&self) -> Option<&str> {
174        self.metadata.id_column.as_deref()
175    }
176
177    /// Resolve a relation of the contained record (e.g. a line's `product`)
178    /// through the host-supplied resolver, back into the real table.
179    fn get_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista> {
180        match &self.ref_resolver {
181            Some(resolve) => resolve(relation, row),
182            None => Err(vantage_core::error!(
183                "contained record has no relation",
184                relation = relation
185            )),
186        }
187    }
188
189    async fn list_vista_values(
190        &self,
191        _vista: &Vista,
192    ) -> Result<IndexMap<String, Record<CborValue>>> {
193        self.im.list_values().await
194    }
195
196    async fn get_vista_value(
197        &self,
198        _vista: &Vista,
199        id: &String,
200    ) -> Result<Option<Record<CborValue>>> {
201        self.im.get_value(id).await
202    }
203
204    async fn get_vista_some_value(
205        &self,
206        _vista: &Vista,
207    ) -> Result<Option<(String, Record<CborValue>)>> {
208        self.im.get_some_value().await
209    }
210
211    async fn get_vista_count(&self, _vista: &Vista) -> Result<i64> {
212        Ok(self.im.list_values().await?.len() as i64)
213    }
214
215    async fn insert_vista_value(
216        &self,
217        _vista: &Vista,
218        id: &String,
219        record: &Record<CborValue>,
220    ) -> Result<Record<CborValue>> {
221        let stored = self.im.insert_value(id, record).await?;
222        self.flush().await?;
223        Ok(stored)
224    }
225
226    async fn insert_vista_return_id_value(
227        &self,
228        _vista: &Vista,
229        record: &Record<CborValue>,
230    ) -> Result<String> {
231        let id = self.next_id(record).await?;
232        self.im.insert_value(&id, record).await?;
233        self.flush().await?;
234        Ok(id)
235    }
236
237    async fn replace_vista_value(
238        &self,
239        _vista: &Vista,
240        id: &String,
241        record: &Record<CborValue>,
242    ) -> Result<Record<CborValue>> {
243        let stored = self.im.replace_value(id, record).await?;
244        self.flush().await?;
245        Ok(stored)
246    }
247
248    async fn patch_vista_value(
249        &self,
250        _vista: &Vista,
251        id: &String,
252        partial: &Record<CborValue>,
253    ) -> Result<Record<CborValue>> {
254        let stored = self.im.patch_value(id, partial).await?;
255        self.flush().await?;
256        Ok(stored)
257    }
258
259    async fn delete_vista_value(&self, _vista: &Vista, id: &String) -> Result<()> {
260        self.im.delete(id).await?;
261        self.flush().await
262    }
263
264    async fn delete_vista_all_values(&self, _vista: &Vista) -> Result<()> {
265        self.im.delete_all().await?;
266        self.flush().await
267    }
268
269    fn capabilities(&self) -> &VistaCapabilities {
270        &self.capabilities
271    }
272
273    fn driver_name(&self) -> &'static str {
274        "contained"
275    }
276}
277
278/// Fixed id for the single record of a `contains_one` relation.
279const ONE_ID: &str = "0";
280
281fn row_id(spec: &ContainedSpec, record: &Record<CborValue>, idx: usize) -> String {
282    spec.id_column
283        .as_deref()
284        .and_then(|c| record.get(c))
285        .map(cbor_scalar_string)
286        .unwrap_or_else(|| idx.to_string())
287}
288
289/// Convert a CBOR map value into a `Record`, or `None` if it isn't a map.
290fn map_to_record(value: &CborValue) -> Option<Record<CborValue>> {
291    matches!(value, CborValue::Map(_)).then(|| Record::<CborValue>::from(value.clone()))
292}
293
294/// Stringify a scalar CBOR value for use as an id.
295fn cbor_scalar_string(value: &CborValue) -> String {
296    match value {
297        CborValue::Text(s) => s.clone(),
298        CborValue::Integer(i) => i128::from(*i).to_string(),
299        other => format!("{other:?}"),
300    }
301}