1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
use std::sync::Arc;
use ciborium::Value as CborValue;
use indexmap::IndexMap;
use vantage_core::{Result, error};
use vantage_types::Record;
use crate::{
capabilities::VistaCapabilities,
column::Column,
flags,
reference::{Reference, ReferenceKind},
sort::SortDirection,
source::TableShell,
};
/// Closure that resolves a cross-persistence reference from a known parent row.
///
/// The closure receives a `Record<CborValue>` (the parent record carrying the
/// join value) and returns a fully-constructed `Vista` from any backend. The
/// closure captures whichever target factory it needs at definition time;
/// `Vista::with_foreign` stores it without ever invoking it.
pub type ForeignResolver = dyn Fn(&Record<CborValue>) -> Result<Vista> + Send + Sync;
/// One cross-persistence reference attached to a `Vista`.
pub struct ForeignRef {
pub kind: ReferenceKind,
pub resolver: Arc<ForeignResolver>,
}
impl std::fmt::Debug for ForeignRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForeignRef")
.field("kind", &self.kind)
.finish_non_exhaustive()
}
}
/// Universal, schema-bearing data handle.
///
/// A `Vista` is produced by a driver factory from a typed `Table<T, E>` or
/// from a YAML schema. The schema (columns, references, id column) lives
/// on the wrapped [`TableShell`] — `Vista` is the user-facing surface that
/// forwards both data and metadata queries to the shell.
///
/// Cross-persistence references (`with_foreign`) are the one exception:
/// they're registered at the Vista layer because they need to capture
/// other-backend factories outside the shell's scope.
pub struct Vista {
pub(crate) name: String,
pub(crate) foreign_resolvers: IndexMap<String, ForeignRef>,
pub(crate) capabilities: VistaCapabilities,
pub source: Box<dyn TableShell>,
}
impl Vista {
pub fn new(name: impl Into<String>, source: Box<dyn TableShell>) -> Self {
let capabilities = source.capabilities().clone();
Self {
name: name.into(),
foreign_resolvers: IndexMap::new(),
capabilities,
source,
}
}
pub fn name(&self) -> &str {
&self.name
}
/// Override the vista's display name. Used by spec-driven construction
/// to expose `spec.name` rather than the underlying file/table name.
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = name.into();
}
pub fn capabilities(&self) -> &VistaCapabilities {
&self.capabilities
}
/// Short human label for the underlying driver (e.g. `"csv"`, `"sqlite"`,
/// `"postgres"`, `"mongodb"`).
pub fn driver(&self) -> &'static str {
self.source.driver_name()
}
// ---- metadata accessors -----------------------------------------------
//
// All schema accessors forward to the shell. Vista holds none of the
// schema state itself; the shell is the source of truth.
pub fn get_id_column(&self) -> Option<&str> {
self.source.id_column()
}
/// Columns flagged `title` (in declaration order).
pub fn get_title_columns(&self) -> Vec<&str> {
self.source
.columns()
.values()
.filter(|c| c.is_title())
.map(|c| c.name.as_str())
.collect()
}
pub fn get_column_names(&self) -> Vec<&str> {
self.source.columns().keys().map(String::as_str).collect()
}
pub fn get_column(&self, name: &str) -> Option<&Column> {
self.source.columns().get(name)
}
/// Names of references attached at the Vista layer — cross-persistence
/// resolvers from [`with_foreign`](Self::with_foreign) and shell-declared
/// references. For the *complete* picture with cardinality, use
/// [`list_references`](Self::list_references) instead.
pub fn get_references(&self) -> Vec<String> {
self.list_references().into_iter().map(|(n, _)| n).collect()
}
/// All references the Vista exposes, with their cardinality.
///
/// Combines two sources: cross-persistence resolvers attached via
/// [`with_foreign`](Self::with_foreign), and same-persistence
/// references declared by the wrapped shell (typically derived from
/// the typed `Table`'s `with_many` / `with_one` registrations via
/// [`TableShell::get_ref_kinds`]). Each is returned once, foreign
/// first, with later duplicates ignored.
pub fn list_references(&self) -> Vec<(String, ReferenceKind)> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for (name, fref) in &self.foreign_resolvers {
if seen.insert(name.clone()) {
out.push((name.clone(), fref.kind));
}
}
for (name, kind) in self.source.get_ref_kinds() {
if seen.insert(name.clone()) {
out.push((name, kind));
}
}
out
}
pub fn get_reference(&self, name: &str) -> Option<&Reference> {
self.source.references().get(name)
}
// ---- conditions --------------------------------------------------------
/// Narrow the vista to records matching `field == value`. Delegates to the
/// underlying driver, which translates the value into its native condition
/// type (BSON document for Mongo, `Expression` for CSV/SQL, …) and applies
/// it to the wrapped table.
///
/// Returns `Err` if the field is unknown to the driver or the value cannot
/// be translated into the driver's condition vocabulary.
pub fn add_condition_eq(&mut self, field: impl Into<String>, value: CborValue) -> Result<()> {
self.source.add_eq_condition(&field.into(), &value)
}
/// Narrow to a single row by id.
///
/// Convenience for the "I only know an id" workflow: pair with
/// [`ReadableValueSet::get_some_value`](vantage_dataset::traits::ReadableValueSet::get_some_value)
/// to fetch the row, then traverse via [`get_ref`](Self::get_ref).
pub fn with_id(&mut self, id: impl Into<CborValue>) -> Result<&mut Self> {
let id_column = self
.get_id_column()
.ok_or_else(|| error!("vista has no id column"))?
.to_string();
self.add_condition_eq(id_column, id.into())?;
Ok(self)
}
// ---- aggregates (not part of ValueSet) ---------------------------------
pub async fn get_count(&self) -> Result<i64> {
self.source.get_vista_count(self).await
}
/// Push a driver-native condition into the wrapped table. The
/// boxed value must match the driver's `T::Condition`. Used by
/// YAML-driven relation factories that need to inject deferred
/// FK eq-conditions (i.e. conditions whose value is resolved at
/// fetch time by reading a parent record).
pub fn add_raw_condition<C: Send + Sync + 'static>(&mut self, condition: C) -> Result<()> {
self.source.add_raw_condition(Box::new(condition))
}
// ---- pagination -------------------------------------------------------
/// Declare how many records constitute one page. Some backends (notably
/// REST APIs with server-fixed page sizes) refuse this — check
/// `capabilities().can_set_page_size` before calling.
pub fn set_page_size(&mut self, size: usize) -> Result<()> {
self.source.set_page_size(size)
}
/// Fetch a specific page (1-based) using offset-style pagination. The
/// per-page count comes from the most recent
/// [`set_page_size`](Self::set_page_size). Returns `Unsupported` when the
/// driver does not advertise `can_fetch_page`; cursor-only drivers
/// (DynamoDB, most token-paginated REST APIs) only support
/// [`fetch_next`](Self::fetch_next) instead.
pub async fn fetch_page(&self, page: usize) -> Result<Vec<(String, Record<CborValue>)>> {
self.source.fetch_page(self, page).await
}
/// Cursor-style chain fetch. Pass `None` on the first call; pass the
/// previous call's returned token on subsequent calls. Returned token is
/// `None` when the result set is exhausted.
///
/// The token is **driver-private** — its shape is whatever the backend
/// uses (DynamoDB `LastEvaluatedKey`, REST `nextToken`, offset count,
/// …). Consumers treat it as opaque and round-trip it back unchanged.
/// Returns `Unsupported` when the driver does not advertise
/// `can_fetch_next`.
pub async fn fetch_next(
&self,
token: Option<CborValue>,
) -> Result<(Vec<(String, Record<CborValue>)>, Option<CborValue>)> {
self.source.fetch_next(self, token).await
}
// ---- quicksearch -------------------------------------------------------
/// Apply a quicksearch filter. The driver decides which columns participate;
/// typically those carrying the [`SEARCHABLE`](crate::flags::SEARCHABLE)
/// flag.
///
/// **Replace semantics**: calling `add_search` again drops the previous
/// search filter. Returns `Unsupported` when the driver does not advertise
/// `can_search`.
pub fn add_search(&mut self, text: impl Into<String>) -> Result<()> {
self.source.add_search(&text.into())
}
/// Drop any quicksearch filter previously applied. Returns `Unsupported`
/// from the driver shell when search is unsupported.
pub fn clear_search(&mut self) -> Result<()> {
self.source.clear_search()
}
// ---- ordering ---------------------------------------------------------
/// Sort results by `column` in the given direction.
///
/// **Replace semantics**: calling `add_order` again wipes the previous
/// order and pushes the new one. V1 supports a single sort column only;
/// multi-column sort can be added later without renaming.
///
/// Returns `Unsupported` when the column is not flagged
/// [`ORDERABLE`](crate::flags::ORDERABLE) — drivers like DynamoDB only
/// flag their declared sort-key columns. Returns `Unsupported` from the
/// driver shell when the driver itself does not support ordering at all
/// (`capabilities().can_order == false`).
pub fn add_order(&mut self, column: &str, dir: SortDirection) -> Result<()> {
let col = self
.source
.columns()
.get(column)
.ok_or_else(|| error!("Unknown column for add_order", column = column))?;
if !col.has_flag(flags::ORDERABLE) {
return Err(error!(
format!("column '{}' is not orderable", column),
column = column
)
.is_unsupported());
}
self.source.add_order(column, dir)
}
/// Wipe every sort previously applied through `add_order`. Returns
/// `Unsupported` from the driver shell when ordering is unsupported.
pub fn clear_orders(&mut self) -> Result<()> {
self.source.clear_orders()
}
// ---- references --------------------------------------------------------
/// Register a cross-persistence reference resolver.
///
/// The closure is **stored, never invoked** at registration time — it
/// fires exactly once, lazily, when [`get_ref`](Self::get_ref) is called
/// for the relation. This guarantees that mutual references between two
/// Vistas (A → B and B → A) don't recurse at construction.
///
/// The `kind` argument records cardinality so consumers
/// ([`list_references`](Self::list_references)) can render the right
/// control — record card for `HasOne`, list grid for `HasMany`.
///
/// The closure receives the parent's row at fire time. Cross-persistence
/// joins on non-PK fields (e.g. `country.id = client.country_id`) work
/// because the closure reads whichever field(s) it needs from the row.
pub fn with_foreign(
&mut self,
relation: impl Into<String>,
kind: ReferenceKind,
resolver: impl Fn(&Record<CborValue>) -> Result<Vista> + Send + Sync + 'static,
) -> &mut Self {
self.foreign_resolvers.insert(
relation.into(),
ForeignRef {
kind,
resolver: Arc::new(resolver),
},
);
self
}
/// Traverse a named reference using a known source row.
///
/// Routes in this order:
/// 1. Cross-persistence resolvers registered via
/// [`with_foreign`](Self::with_foreign).
/// 2. Same-persistence refs forwarded through
/// [`TableShell::get_ref`], which consults the wrapped typed `Table`'s
/// `with_one` / `with_many` registrations.
///
/// The `row` must come from this Vista (typically via
/// [`get_value`](vantage_dataset::traits::ReadableValueSet::get_value)
/// or [`get_some_value`](vantage_dataset::traits::ReadableValueSet::get_some_value)).
/// The join value is read out of the record and pushed as a plain
/// eq-condition on the target — no subqueries, no deferred fetch.
pub fn get_ref(&self, relation: &str, row: &Record<CborValue>) -> Result<Vista> {
if let Some(fref) = self.foreign_resolvers.get(relation) {
return (fref.resolver)(row);
}
if self.source.contained().contains_key(relation) {
return self.source.get_contained_ref(relation, row);
}
self.source.get_ref(relation, row)
}
/// Contained (embedded-in-row) relations the Vista exposes, with their
/// cardinality. Distinct from [`list_references`](Self::list_references),
/// which covers foreign-key relations.
pub fn list_contained(&self) -> Vec<(String, crate::reference::ContainedKind)> {
self.source
.contained()
.values()
.map(|s| (s.name.clone(), s.kind))
.collect()
}
/// Build the bare target of a same-persistence relation — the unconditioned
/// table a new related row is inserted into. Forwards to
/// [`TableShell::get_ref_target`]. Cross-persistence relations registered
/// via [`with_foreign`](Self::with_foreign) are not insertable this way and
/// error here.
pub fn get_ref_target(&self, relation: &str) -> Result<Vista> {
if self.foreign_resolvers.contains_key(relation) {
return Err(error!(
"cross-persistence nested insert is not supported",
relation = relation
));
}
self.source.get_ref_target(relation)
}
}