cratestack_core/schema/view.rs
1//! View IR — the `view <Name> from <Model>, ... { ... }` block.
2//!
3//! Views are read-only, SQL-defined projections over one or more
4//! existing `model` blocks (see ADR-0003). Their fields, attributes,
5//! and span tracking mirror [`Model`](super::Model); the extra state is
6//! the explicit source-model dependency list and the per-backend SQL
7//! bodies parsed out of `@@server_sql` / `@@embedded_sql` / `@@sql`.
8
9use serde::{Deserialize, Serialize};
10
11use super::{Attribute, Field, SourceSpan};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct View {
15 pub docs: Vec<String>,
16 pub name: String,
17 pub name_span: SourceSpan,
18 /// The `from <Model>, <Model>, ...` dependency list. Source model
19 /// names are stored as raw identifiers — the validator resolves
20 /// them against the schema's models. Carries spans so error
21 /// reporting can point at the offending identifier.
22 pub sources: Vec<ViewSource>,
23 pub fields: Vec<Field>,
24 /// Block-level attributes — `@@server_sql`, `@@embedded_sql`,
25 /// `@@sql`, `@@materialized`, `@@no_unique`, `@@allow("read", …)`.
26 /// Stored raw; helper methods below extract typed views.
27 pub attributes: Vec<Attribute>,
28 pub span: SourceSpan,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ViewSource {
33 pub name: String,
34 pub name_span: SourceSpan,
35}
36
37impl View {
38 /// Returns the SQL body declared via `@@server_sql("…")`, or the
39 /// `@@sql("…")` shorthand if no backend-specific body is set.
40 /// `None` means the view is embedded-only.
41 pub fn server_sql(&self) -> Option<&str> {
42 self.body_attribute("@@server_sql")
43 .or_else(|| self.body_attribute("@@sql"))
44 }
45
46 /// Returns the SQL body declared via `@@embedded_sql("…")`, or the
47 /// `@@sql("…")` shorthand if no backend-specific body is set.
48 /// `None` means the view is server-only.
49 pub fn embedded_sql(&self) -> Option<&str> {
50 self.body_attribute("@@embedded_sql")
51 .or_else(|| self.body_attribute("@@sql"))
52 }
53
54 /// `true` if the view was declared with `@@materialized`.
55 /// Materialized views are server-only — the embedded composer
56 /// emits a hard compile error when it encounters one.
57 pub fn is_materialized(&self) -> bool {
58 self.has_bare_attribute("@@materialized")
59 }
60
61 /// `true` if the view opts out of a natural unique key via
62 /// `@@no_unique`. Drops `find_unique` from the generated delegate.
63 pub fn no_unique(&self) -> bool {
64 self.has_bare_attribute("@@no_unique")
65 }
66
67 fn body_attribute(&self, prefix: &str) -> Option<&str> {
68 self.attributes
69 .iter()
70 .filter(|attr| attr.raw.starts_with(prefix))
71 .find_map(|attr| extract_sql_body(&attr.raw, prefix))
72 }
73
74 fn has_bare_attribute(&self, name: &str) -> bool {
75 self.attributes.iter().any(|attr| {
76 let trimmed = attr.raw.trim();
77 trimmed == name || trimmed.starts_with(&format!("{name}(")) || trimmed == name
78 })
79 }
80}
81
82/// Extract the SQL body from an attribute like `@@server_sql("…")`.
83/// Accepts both `"single-line"` and `"""multi-line"""` strings. The
84/// outer quotes are stripped; embedded newlines and quotes are
85/// preserved verbatim.
86fn extract_sql_body<'a>(raw: &'a str, prefix: &str) -> Option<&'a str> {
87 let after_prefix = raw.strip_prefix(prefix)?.trim_start();
88 let inside_parens = after_prefix
89 .strip_prefix('(')?
90 .rsplit_once(')')
91 .map(|(body, _tail)| body)?
92 .trim();
93 if let Some(rest) = inside_parens.strip_prefix("\"\"\"") {
94 rest.strip_suffix("\"\"\"")
95 } else if let Some(rest) = inside_parens.strip_prefix('"') {
96 rest.strip_suffix('"')
97 } else {
98 None
99 }
100}