1use serde::{Deserialize, Serialize};
4
5use crate::{
6 directives::TableDisplay,
7 errors::{Diagnostic, McdError, Result},
8 package::McdPackage,
9 schema::{ColumnType, TableSchema},
10};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct TableView {
16 pub id: String,
18 pub table: String,
20 #[serde(default)]
22 pub display: TableDisplay,
23 #[serde(default)]
25 pub columns: Vec<ViewColumn>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub chart: Option<ChartSpec>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub style: Option<serde_json::Value>,
32}
33
34impl TableView {
35 pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
37 let bytes = package.read(path).map_err(|_| {
38 McdError::from_diagnostic(
39 Diagnostic::error(
40 "view.file.missing",
41 format!("Declared table view file '{path}' is missing."),
42 )
43 .with_source(path.to_owned()),
44 )
45 })?;
46 serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
47 }
48
49 pub fn validate(
51 &self,
52 expected_id: &str,
53 table_id: &str,
54 schema: &TableSchema,
55 source: &str,
56 ) -> Result<()> {
57 if self.id != expected_id {
58 return Err(view_error(
59 "view.id.mismatch",
60 format!(
61 "View id '{}' does not match manifest view id '{}'.",
62 self.id, expected_id
63 ),
64 source,
65 ));
66 }
67 if self.table != table_id {
68 return Err(view_error(
69 "view.table.mismatch",
70 format!(
71 "View '{}' references table '{}', but manifest attaches it to '{}'.",
72 self.id, self.table, table_id
73 ),
74 source,
75 ));
76 }
77
78 match self.display {
79 TableDisplay::Table => self.validate_table_columns(schema, source),
80 TableDisplay::Chart => self.validate_chart(schema, source),
81 }
82 }
83
84 fn validate_table_columns(&self, schema: &TableSchema, source: &str) -> Result<()> {
85 for column in &self.columns {
86 if !schema.has_column(&column.name) {
87 return Err(view_error(
88 "view.column.unknown",
89 format!(
90 "View '{}' references unknown schema column '{}'.",
91 self.id, column.name
92 ),
93 source,
94 ));
95 }
96 validate_format_declarations(
97 column.format.as_deref(),
98 column.currency.as_deref(),
99 column.unit_label.as_deref(),
100 column.percent,
101 schema
102 .column(&column.name)
103 .map(|schema_column| schema_column.value_type),
104 source,
105 )?;
106 }
107 Ok(())
108 }
109
110 fn validate_chart(&self, schema: &TableSchema, source: &str) -> Result<()> {
111 let Some(chart) = &self.chart else {
112 return Err(view_error(
113 "chart.spec.missing",
114 format!("Chart view '{}' must include a chart object.", self.id),
115 source,
116 ));
117 };
118 chart.validate(schema, source)?;
119 Ok(())
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ViewColumn {
127 pub name: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub label: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub format: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub currency: Option<String>,
138 #[serde(
140 default,
141 rename = "unitLabel",
142 alias = "unit",
143 skip_serializing_if = "Option::is_none"
144 )]
145 pub unit_label: Option<String>,
146 #[serde(default)]
148 pub percent: bool,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ChartSpec {
155 #[serde(rename = "type")]
157 pub chart_type: ChartType,
158 pub x: ChartEncoding,
160 pub y: ChartEncoding,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub series: Option<ChartEncoding>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub grouping: Option<ChartEncoding>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub mark_labels: Option<MarkLabels>,
171}
172
173impl ChartSpec {
174 fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
175 self.x.validate_known_column(schema, source)?;
176 self.y.validate_known_column(schema, source)?;
177 if let Some(series) = &self.series {
178 series.validate_known_column(schema, source)?;
179 }
180 if let Some(grouping) = &self.grouping {
181 grouping.validate_known_column(schema, source)?;
182 }
183 if let Some(mark_labels) = &self.mark_labels {
184 mark_labels.validate(schema, source)?;
185 }
186
187 let y_type = column_type(schema, &self.y.column, source)?;
188 if !y_type.is_numeric() {
189 return Err(view_error(
190 "chart.column.type.incompatible",
191 format!(
192 "Chart y column '{}' must be integer or decimal.",
193 self.y.column
194 ),
195 source,
196 ));
197 }
198 self.y.validate_format(schema, source)?;
199 self.x.validate_format(schema, source)?;
200
201 if self.chart_type == ChartType::Scatter {
202 let x_type = column_type(schema, &self.x.column, source)?;
203 if !x_type.is_numeric() && !x_type.is_temporal() {
204 return Err(view_error(
205 "chart.column.type.incompatible",
206 format!(
207 "Scatter chart x column '{}' must be numeric or temporal.",
208 self.x.column
209 ),
210 source,
211 ));
212 }
213 }
214
215 Ok(())
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "lowercase")]
222pub enum ChartType {
223 Bar,
225 Line,
227 Area,
229 Scatter,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct ChartEncoding {
237 pub column: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub label: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub format: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub currency: Option<String>,
248 #[serde(
250 default,
251 rename = "unitLabel",
252 alias = "unit",
253 skip_serializing_if = "Option::is_none"
254 )]
255 pub unit_label: Option<String>,
256 #[serde(default)]
258 pub percent: bool,
259}
260
261impl ChartEncoding {
262 fn validate_known_column(&self, schema: &TableSchema, source: &str) -> Result<()> {
263 if schema.has_column(&self.column) {
264 Ok(())
265 } else {
266 Err(view_error(
267 "chart.column.unknown",
268 format!("Chart references unknown schema column '{}'.", self.column),
269 source,
270 ))
271 }
272 }
273
274 fn validate_format(&self, schema: &TableSchema, source: &str) -> Result<()> {
275 validate_format_declarations(
276 self.format.as_deref(),
277 self.currency.as_deref(),
278 self.unit_label.as_deref(),
279 self.percent,
280 schema.column(&self.column).map(|column| column.value_type),
281 source,
282 )
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct MarkLabels {
290 #[serde(default)]
292 pub show: bool,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub column: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub format: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub currency: Option<String>,
302 #[serde(
304 default,
305 rename = "unitLabel",
306 alias = "unit",
307 skip_serializing_if = "Option::is_none"
308 )]
309 pub unit_label: Option<String>,
310 #[serde(default)]
312 pub percent: bool,
313}
314
315impl MarkLabels {
316 fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
317 if let Some(column) = &self.column {
318 if !schema.has_column(column) {
319 return Err(view_error(
320 "chart.column.unknown",
321 format!("Chart mark labels reference unknown schema column '{column}'."),
322 source,
323 ));
324 }
325 validate_format_declarations(
326 self.format.as_deref(),
327 self.currency.as_deref(),
328 self.unit_label.as_deref(),
329 self.percent,
330 schema
331 .column(column)
332 .map(|schema_column| schema_column.value_type),
333 source,
334 )?;
335 }
336 Ok(())
337 }
338}
339
340fn validate_format_declarations(
341 format: Option<&str>,
342 currency: Option<&str>,
343 _unit: Option<&str>,
344 percent: bool,
345 column_type: Option<ColumnType>,
346 source: &str,
347) -> Result<()> {
348 if currency.is_some() && format != Some("currency") {
349 return Err(view_error(
350 "view.format.currency.inconsistent",
351 "Currency declarations require format: currency.",
352 source,
353 ));
354 }
355 if percent && format.is_some_and(|format| format != "percent") {
356 return Err(view_error(
357 "view.format.percent.inconsistent",
358 "Percent declarations must use format: percent when a format is declared.",
359 source,
360 ));
361 }
362
363 if matches!(format, Some("currency" | "number" | "percent")) {
364 let Some(column_type) = column_type else {
365 return Ok(());
366 };
367 if !column_type.is_numeric() {
368 return Err(view_error(
369 "view.format.type.incompatible",
370 "Numeric, currency, and percent formats require integer or decimal columns.",
371 source,
372 ));
373 }
374 }
375
376 if matches!(format, Some("date" | "datetime" | "time")) {
377 let Some(column_type) = column_type else {
378 return Ok(());
379 };
380 if !column_type.is_temporal() {
381 return Err(view_error(
382 "view.format.type.incompatible",
383 "Date, datetime, and time formats require temporal columns.",
384 source,
385 ));
386 }
387 }
388
389 Ok(())
390}
391
392fn column_type(schema: &TableSchema, column: &str, source: &str) -> Result<ColumnType> {
393 schema
394 .column(column)
395 .map(|column| column.value_type)
396 .ok_or_else(|| {
397 view_error(
398 "chart.column.unknown",
399 format!("Chart references unknown schema column '{column}'."),
400 source,
401 )
402 })
403}
404
405fn view_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
406 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::schema::{ColumnType, TableColumnSchema};
413
414 fn schema() -> TableSchema {
415 TableSchema {
416 id: "revenue".to_owned(),
417 primary_key: Vec::new(),
418 foreign_keys: Vec::new(),
419 columns: vec![
420 TableColumnSchema {
421 name: "quarter".to_owned(),
422 value_type: ColumnType::String,
423 label: None,
424 unit: None,
425 nullable: false,
426 enum_values: Vec::new(),
427 },
428 TableColumnSchema {
429 name: "amount".to_owned(),
430 value_type: ColumnType::Decimal,
431 label: None,
432 unit: None,
433 nullable: false,
434 enum_values: Vec::new(),
435 },
436 ],
437 }
438 }
439
440 #[test]
441 fn validates_chart_columns() {
442 let view = serde_json::from_str::<TableView>(
443 r#"{
444 "id":"chart",
445 "table":"revenue",
446 "display":"chart",
447 "chart":{
448 "type":"bar",
449 "x":{"column":"quarter"},
450 "y":{"column":"amount","format":"currency","currency":"GBP"}
451 }
452 }"#,
453 )
454 .expect("view parses");
455
456 view.validate("chart", "revenue", &schema(), "tables/chart.view.json")
457 .expect("valid chart view");
458 }
459
460 #[test]
461 fn accepts_unit_label_and_legacy_unit_display_fields() {
462 let unit_label = serde_json::from_str::<TableView>(
463 r#"{
464 "id":"default",
465 "table":"revenue",
466 "columns":[{"name":"amount","unitLabel":"kg"}]
467 }"#,
468 )
469 .expect("view parses");
470 assert_eq!(unit_label.columns[0].unit_label.as_deref(), Some("kg"));
471
472 let legacy_unit = serde_json::from_str::<TableView>(
473 r#"{
474 "id":"default",
475 "table":"revenue",
476 "columns":[{"name":"amount","unit":"kg"}]
477 }"#,
478 )
479 .expect("view parses");
480 assert_eq!(legacy_unit.columns[0].unit_label.as_deref(), Some("kg"));
481 }
482
483 #[test]
484 fn rejects_unknown_view_column() {
485 let view = serde_json::from_str::<TableView>(
486 r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
487 )
488 .expect("view parses");
489 let err = view
490 .validate("default", "revenue", &schema(), "tables/view.json")
491 .expect_err("invalid");
492
493 assert_eq!(
494 err.diagnostic().map(|d| d.code.as_str()),
495 Some("view.column.unknown")
496 );
497 }
498}