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.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(default, skip_serializing_if = "Option::is_none")]
140 pub unit: Option<String>,
141 #[serde(default)]
143 pub percent: bool,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct ChartSpec {
150 #[serde(rename = "type")]
152 pub chart_type: ChartType,
153 pub x: ChartEncoding,
155 pub y: ChartEncoding,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub series: Option<ChartEncoding>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub grouping: Option<ChartEncoding>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub mark_labels: Option<MarkLabels>,
166}
167
168impl ChartSpec {
169 fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
170 self.x.validate_known_column(schema, source)?;
171 self.y.validate_known_column(schema, source)?;
172 if let Some(series) = &self.series {
173 series.validate_known_column(schema, source)?;
174 }
175 if let Some(grouping) = &self.grouping {
176 grouping.validate_known_column(schema, source)?;
177 }
178 if let Some(mark_labels) = &self.mark_labels {
179 mark_labels.validate(schema, source)?;
180 }
181
182 let y_type = column_type(schema, &self.y.column, source)?;
183 if !y_type.is_numeric() {
184 return Err(view_error(
185 "chart.column.type.incompatible",
186 format!(
187 "Chart y column '{}' must be integer or decimal.",
188 self.y.column
189 ),
190 source,
191 ));
192 }
193 self.y.validate_format(schema, source)?;
194 self.x.validate_format(schema, source)?;
195
196 if self.chart_type == ChartType::Scatter {
197 let x_type = column_type(schema, &self.x.column, source)?;
198 if !x_type.is_numeric() && !x_type.is_temporal() {
199 return Err(view_error(
200 "chart.column.type.incompatible",
201 format!(
202 "Scatter chart x column '{}' must be numeric or temporal.",
203 self.x.column
204 ),
205 source,
206 ));
207 }
208 }
209
210 Ok(())
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "lowercase")]
217pub enum ChartType {
218 Bar,
220 Line,
222 Area,
224 Scatter,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct ChartEncoding {
232 pub column: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub label: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub format: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub currency: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub unit: Option<String>,
246 #[serde(default)]
248 pub percent: bool,
249}
250
251impl ChartEncoding {
252 fn validate_known_column(&self, schema: &TableSchema, source: &str) -> Result<()> {
253 if schema.has_column(&self.column) {
254 Ok(())
255 } else {
256 Err(view_error(
257 "chart.column.unknown",
258 format!("Chart references unknown schema column '{}'.", self.column),
259 source,
260 ))
261 }
262 }
263
264 fn validate_format(&self, schema: &TableSchema, source: &str) -> Result<()> {
265 validate_format_declarations(
266 self.format.as_deref(),
267 self.currency.as_deref(),
268 self.unit.as_deref(),
269 self.percent,
270 schema.column(&self.column).map(|column| column.value_type),
271 source,
272 )
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct MarkLabels {
280 #[serde(default)]
282 pub show: bool,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub column: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub format: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub currency: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub unit: Option<String>,
295 #[serde(default)]
297 pub percent: bool,
298}
299
300impl MarkLabels {
301 fn validate(&self, schema: &TableSchema, source: &str) -> Result<()> {
302 if let Some(column) = &self.column {
303 if !schema.has_column(column) {
304 return Err(view_error(
305 "chart.column.unknown",
306 format!("Chart mark labels reference unknown schema column '{column}'."),
307 source,
308 ));
309 }
310 validate_format_declarations(
311 self.format.as_deref(),
312 self.currency.as_deref(),
313 self.unit.as_deref(),
314 self.percent,
315 schema
316 .column(column)
317 .map(|schema_column| schema_column.value_type),
318 source,
319 )?;
320 }
321 Ok(())
322 }
323}
324
325fn validate_format_declarations(
326 format: Option<&str>,
327 currency: Option<&str>,
328 _unit: Option<&str>,
329 percent: bool,
330 column_type: Option<ColumnType>,
331 source: &str,
332) -> Result<()> {
333 if currency.is_some() && format != Some("currency") {
334 return Err(view_error(
335 "view.format.currency.inconsistent",
336 "Currency declarations require format: currency.",
337 source,
338 ));
339 }
340 if percent && format.is_some_and(|format| format != "percent") {
341 return Err(view_error(
342 "view.format.percent.inconsistent",
343 "Percent declarations must use format: percent when a format is declared.",
344 source,
345 ));
346 }
347
348 if matches!(format, Some("currency" | "number" | "percent")) {
349 let Some(column_type) = column_type else {
350 return Ok(());
351 };
352 if !column_type.is_numeric() {
353 return Err(view_error(
354 "view.format.type.incompatible",
355 "Numeric, currency, and percent formats require integer or decimal columns.",
356 source,
357 ));
358 }
359 }
360
361 if matches!(format, Some("date" | "datetime" | "time")) {
362 let Some(column_type) = column_type else {
363 return Ok(());
364 };
365 if !column_type.is_temporal() {
366 return Err(view_error(
367 "view.format.type.incompatible",
368 "Date, datetime, and time formats require temporal columns.",
369 source,
370 ));
371 }
372 }
373
374 Ok(())
375}
376
377fn column_type(schema: &TableSchema, column: &str, source: &str) -> Result<ColumnType> {
378 schema
379 .column(column)
380 .map(|column| column.value_type)
381 .ok_or_else(|| {
382 view_error(
383 "chart.column.unknown",
384 format!("Chart references unknown schema column '{column}'."),
385 source,
386 )
387 })
388}
389
390fn view_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
391 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::schema::{ColumnType, TableColumnSchema};
398
399 fn schema() -> TableSchema {
400 TableSchema {
401 id: "revenue".to_owned(),
402 columns: vec![
403 TableColumnSchema {
404 name: "quarter".to_owned(),
405 value_type: ColumnType::String,
406 label: None,
407 nullable: false,
408 enum_values: Vec::new(),
409 },
410 TableColumnSchema {
411 name: "amount".to_owned(),
412 value_type: ColumnType::Decimal,
413 label: None,
414 nullable: false,
415 enum_values: Vec::new(),
416 },
417 ],
418 }
419 }
420
421 #[test]
422 fn validates_chart_columns() {
423 let view = serde_json::from_str::<TableView>(
424 r#"{
425 "id":"chart",
426 "table":"revenue",
427 "display":"chart",
428 "chart":{
429 "type":"bar",
430 "x":{"column":"quarter"},
431 "y":{"column":"amount","format":"currency","currency":"GBP"}
432 }
433 }"#,
434 )
435 .expect("view parses");
436
437 view.validate("chart", "revenue", &schema(), "tables/chart.view.json")
438 .expect("valid chart view");
439 }
440
441 #[test]
442 fn rejects_unknown_view_column() {
443 let view = serde_json::from_str::<TableView>(
444 r#"{"id":"default","table":"revenue","columns":[{"name":"missing"}]}"#,
445 )
446 .expect("view parses");
447 let err = view
448 .validate("default", "revenue", &schema(), "tables/view.json")
449 .expect_err("invalid");
450
451 assert_eq!(
452 err.diagnostic().map(|d| d.code.as_str()),
453 Some("view.column.unknown")
454 );
455 }
456}