Skip to main content

trilogy_parser/
parser.rs

1// Dependency-resolution view over the full Trilogy grammar. Walks the
2// `trilogy.pest` parse tree produced by `TrilogyParser` and extracts only the
3// three statement kinds that matter for dependency ordering: imports,
4// datasources, and persists. Everything else is ignored.
5//
6// Historically this module had its own permissive grammar (`preql.pest`) that
7// could parse partial / malformed files. The strict grammar refuses those, so
8// directory_resolver callers now surface a warning for files that can't parse
9// cleanly (the lark/pest pipelines would reject them at compile time anyway).
10
11use crate::trilogy_parser::{Rule, TrilogyParser};
12use pest::iterators::Pair;
13use pest::Parser;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct ImportStatement {
19    pub raw_path: String,
20    pub parent_dirs: usize,
21    pub alias: Option<String>,
22    pub is_stdlib: bool,
23}
24
25impl ImportStatement {
26    pub fn resolve(&self, working_dir: &Path) -> Option<PathBuf> {
27        if self.is_stdlib {
28            return None;
29        }
30
31        let mut base = working_dir.to_path_buf();
32        for _ in 0..self.parent_dirs {
33            base = base.parent()?.to_path_buf();
34        }
35        for part in self.raw_path.split('.') {
36            base.push(part);
37        }
38        base.set_extension("preql");
39        Some(base)
40    }
41
42    pub fn effective_alias(&self) -> &str {
43        self.alias
44            .as_deref()
45            .unwrap_or_else(|| self.raw_path.split('.').last().unwrap_or(&self.raw_path))
46    }
47}
48
49/// How a datasource is backed. Only `Literal` yields a physical address that
50/// can be joined against externally-observed state; `Templated` addresses
51/// resolve at run time and are surfaced raw rather than silently dropped.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum AddressKind {
54    /// `address x.y` or a backtick-quoted literal — a physical warehouse table.
55    Literal,
56    /// `address f`...`` — an f-string; `address` holds the raw template.
57    Templated,
58    /// `query ...` — a view over other assets; no physical table.
59    Query,
60    /// `file ...` — a local file source; `address` holds the raw spec.
61    File,
62}
63
64impl AddressKind {
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            AddressKind::Literal => "literal",
68            AddressKind::Templated => "templated",
69            AddressKind::Query => "query",
70            AddressKind::File => "file",
71        }
72    }
73}
74
75impl std::fmt::Display for AddressKind {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(f, "{}", self.as_str())
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub struct DatasourceDeclaration {
83    pub name: String,
84    /// Physical address for `Literal` (quoting stripped), raw template for
85    /// `Templated`, raw path spec for `File`, `None` for `Query`.
86    pub address: Option<String>,
87    pub address_kind: AddressKind,
88    /// `root datasource` — a source the script reads, not a managed asset it writes.
89    pub is_root: bool,
90    /// `partial datasource` — covers only a subset of its grain, so it cannot
91    /// answer a query on its own.
92    pub is_partial: bool,
93    /// Declares a `partition by` clause.
94    pub is_partitioned: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98pub struct PersistStatement {
99    pub mode: PersistMode,
100    pub target_datasource: String,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum PersistMode {
105    Append,
106    Overwrite,
107    Persist,
108}
109
110impl std::fmt::Display for PersistMode {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            PersistMode::Append => write!(f, "append"),
114            PersistMode::Overwrite => write!(f, "overwrite"),
115            PersistMode::Persist => write!(f, "persist"),
116        }
117    }
118}
119
120#[derive(Debug, Clone, Default)]
121pub struct ParsedFile {
122    pub imports: Vec<ImportStatement>,
123    pub datasources: Vec<DatasourceDeclaration>,
124    pub persists: Vec<PersistStatement>,
125}
126
127#[derive(Error, Debug)]
128pub enum ParseError {
129    #[error("Failed to parse file: {0}")]
130    PestError(#[from] pest::error::Error<Rule>),
131
132    #[error("Invalid import statement structure")]
133    InvalidImportStructure,
134
135    #[error("Invalid datasource statement structure")]
136    InvalidDatasourceStructure,
137
138    #[error("Invalid persist statement structure")]
139    InvalidPersistStructure,
140}
141
142pub fn parse_file(content: &str) -> Result<ParsedFile, ParseError> {
143    let mut pairs = TrilogyParser::parse(Rule::start, content)?;
144    let start = pairs
145        .next()
146        .ok_or(ParseError::InvalidImportStructure)?;
147
148    let mut result = ParsedFile::default();
149    for top in start.into_inner() {
150        if top.as_rule() != Rule::block {
151            continue;
152        }
153        // block = { statement ~ _TERMINATOR }; `statement` is silent, so its
154        // inner rule (import_statement, datasource, persist_statement, ...)
155        // appears as a direct child of block.
156        for stmt in top.into_inner() {
157            match stmt.as_rule() {
158                // `from x.y import a, b` is the same file edge as `import x.y`;
159                // the concept list rides in its own `import_concepts` child, so
160                // the shared extractor sees only the path (and alias) tokens.
161                // `self import as X` re-imports the current file under a
162                // namespace and creates no cross-file edge, so it is ignored.
163                Rule::import_statement | Rule::selective_import_statement => {
164                    result.imports.push(extract_import(stmt)?);
165                }
166                Rule::datasource => {
167                    result.datasources.push(extract_datasource(stmt)?);
168                }
169                Rule::persist_statement => {
170                    result.persists.push(extract_persist(stmt)?);
171                }
172                _ => {}
173            }
174        }
175    }
176
177    Ok(result)
178}
179
180pub fn parse_imports(content: &str) -> Result<Vec<ImportStatement>, ParseError> {
181    Ok(parse_file(content)?.imports)
182}
183
184// import_statement = { ^"import" ~ IMPORT_DOT* ~ dotted_identifier_tail ~ (^"as" ~ IDENTIFIER)? }
185// `dotted_identifier_tail` is silent, so children are the IMPORT_DOT tokens
186// followed by IDENTIFIER tokens for every path component and the optional alias.
187fn extract_import(pair: Pair<Rule>) -> Result<ImportStatement, ParseError> {
188    let full_text = pair.as_str();
189    let mut n_dots = 0usize;
190    let mut idents: Vec<String> = Vec::new();
191    for child in pair.into_inner() {
192        match child.as_rule() {
193            Rule::IMPORT_DOT => n_dots += 1,
194            Rule::IDENTIFIER => idents.push(child.as_str().to_string()),
195            _ => {}
196        }
197    }
198    if idents.is_empty() {
199        return Err(ParseError::InvalidImportStructure);
200    }
201
202    // Whether the final identifier is an alias. `as` is a reserved keyword, so
203    // a bare `as` token inside the statement text is unambiguous.
204    let has_alias = full_text
205        .split_ascii_whitespace()
206        .any(|tok| tok.eq_ignore_ascii_case("as"));
207    let alias = if has_alias && idents.len() >= 2 {
208        Some(idents.pop().unwrap())
209    } else {
210        None
211    };
212
213    let raw_path = idents.join(".");
214    let is_stdlib = raw_path == "std" || raw_path.starts_with("std.");
215    // Historical convention: leading dot prefix `..` means "one level up", so
216    // the first dot is part of the relative-import syntax and each extra dot
217    // adds one parent traversal.
218    let parent_dirs = n_dots.saturating_sub(1);
219
220    Ok(ImportStatement {
221        raw_path,
222        parent_dirs,
223        alias,
224        is_stdlib,
225    })
226}
227
228// datasource = { DATASOURCE_ROOT? ~ (DATASOURCE_PARTIAL | SHORTHAND_MODIFIER)? ~ "datasource" ~ IDENTIFIER ~ "(" ~ ... }
229// The first direct IDENTIFIER child is always the datasource name; the
230// backing (address | query | file) and the partition clause are direct
231// children as well.
232fn extract_datasource(pair: Pair<Rule>) -> Result<DatasourceDeclaration, ParseError> {
233    let mut name: Option<String> = None;
234    let mut address: Option<String> = None;
235    let mut address_kind: Option<AddressKind> = None;
236    let mut is_root = false;
237    let mut is_partial = false;
238    let mut is_partitioned = false;
239
240    for child in pair.into_inner() {
241        match child.as_rule() {
242            Rule::DATASOURCE_ROOT => is_root = true,
243            Rule::DATASOURCE_PARTIAL => is_partial = true,
244            Rule::IDENTIFIER if name.is_none() => name = Some(child.as_str().to_string()),
245            // address = { "address" ~ (F_QUOTED_ADDRESS | QUOTED_ADDRESS | ADDRESS) }
246            Rule::address => {
247                let tok = child
248                    .into_inner()
249                    .next()
250                    .ok_or(ParseError::InvalidDatasourceStructure)?;
251                match tok.as_rule() {
252                    Rule::F_QUOTED_ADDRESS => {
253                        // f`...` — keep the raw template body; it cannot be
254                        // resolved statically and must not be dropped.
255                        address_kind = Some(AddressKind::Templated);
256                        address = Some(
257                            tok.as_str()
258                                .trim_start_matches(['f', 'F'])
259                                .trim_matches('`')
260                                .to_string(),
261                        );
262                    }
263                    Rule::QUOTED_ADDRESS => {
264                        // `...` with an optional inner '...' layer.
265                        address_kind = Some(AddressKind::Literal);
266                        address = Some(
267                            tok.as_str().trim_matches('`').trim_matches('\'').to_string(),
268                        );
269                    }
270                    _ => {
271                        address_kind = Some(AddressKind::Literal);
272                        address = Some(tok.as_str().to_string());
273                    }
274                }
275            }
276            Rule::query => address_kind = Some(AddressKind::Query),
277            Rule::file => {
278                address_kind = Some(AddressKind::File);
279                // Raw spec after the (always 4-byte) `file` keyword.
280                address = Some(child.as_str()[4..].trim().to_string());
281            }
282            Rule::datasource_partition_clause => is_partitioned = true,
283            _ => {}
284        }
285    }
286
287    match (name, address_kind) {
288        (Some(name), Some(address_kind)) => Ok(DatasourceDeclaration {
289            name,
290            address,
291            address_kind,
292            is_root,
293            is_partial,
294            is_partitioned,
295        }),
296        _ => Err(ParseError::InvalidDatasourceStructure),
297    }
298}
299
300fn extract_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
301    // persist_statement = { full_persist | auto_persist }
302    let inner = pair
303        .into_inner()
304        .next()
305        .ok_or(ParseError::InvalidPersistStructure)?;
306    match inner.as_rule() {
307        Rule::auto_persist => extract_auto_persist(inner),
308        Rule::full_persist => extract_full_persist(inner),
309        _ => Err(ParseError::InvalidPersistStructure),
310    }
311}
312
313// auto_persist = { PERSIST_MODE ~ IDENTIFIER ~ where? }
314fn extract_auto_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
315    let mut mode: Option<PersistMode> = None;
316    let mut target: Option<String> = None;
317    for child in pair.into_inner() {
318        match child.as_rule() {
319            Rule::PERSIST_MODE => mode = Some(parse_persist_mode(child.as_str())),
320            Rule::IDENTIFIER if target.is_none() => {
321                target = Some(child.as_str().to_string());
322            }
323            _ => {}
324        }
325    }
326    match (mode, target) {
327        (Some(mode), Some(target_datasource)) => Ok(PersistStatement {
328            mode,
329            target_datasource,
330        }),
331        _ => Err(ParseError::InvalidPersistStructure),
332    }
333}
334
335// full_persist = { PERSIST_MODE ~ (!"into" ~ IDENTIFIER)? ~ "into" ~ IDENTIFIER ~ persist_partition_clause? ~ "from" ~ select_statement }
336// Literals (`into`, `from`) are not emitted as children, so we see PERSIST_MODE,
337// optionally a source IDENTIFIER, then the target IDENTIFIER, then the select
338// subtree. Taking the LAST direct IDENTIFIER yields the post-`into` target.
339fn extract_full_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
340    let mut mode: Option<PersistMode> = None;
341    let mut last_ident: Option<String> = None;
342    for child in pair.into_inner() {
343        match child.as_rule() {
344            Rule::PERSIST_MODE => mode = Some(parse_persist_mode(child.as_str())),
345            Rule::IDENTIFIER => last_ident = Some(child.as_str().to_string()),
346            _ => {}
347        }
348    }
349    match (mode, last_ident) {
350        (Some(mode), Some(target_datasource)) => Ok(PersistStatement {
351            mode,
352            target_datasource,
353        }),
354        _ => Err(ParseError::InvalidPersistStructure),
355    }
356}
357
358fn parse_persist_mode(s: &str) -> PersistMode {
359    match s.to_ascii_lowercase().as_str() {
360        "append" => PersistMode::Append,
361        "overwrite" => PersistMode::Overwrite,
362        _ => PersistMode::Persist,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_simple_import() {
372        let parsed = parse_file("import models.customer;").unwrap();
373        assert_eq!(parsed.imports.len(), 1);
374        assert_eq!(parsed.imports[0].raw_path, "models.customer");
375        assert_eq!(parsed.imports[0].parent_dirs, 0);
376        assert!(parsed.imports[0].alias.is_none());
377    }
378
379    #[test]
380    fn test_import_with_alias() {
381        let parsed = parse_file("import models.customer as cust;").unwrap();
382        assert_eq!(parsed.imports.len(), 1);
383        assert_eq!(parsed.imports[0].raw_path, "models.customer");
384        assert_eq!(parsed.imports[0].alias, Some("cust".to_string()));
385    }
386
387    #[test]
388    fn test_relative_import() {
389        let parsed = parse_file("import ..models.customer;").unwrap();
390        assert_eq!(parsed.imports.len(), 1);
391        assert_eq!(parsed.imports[0].raw_path, "models.customer");
392        assert_eq!(parsed.imports[0].parent_dirs, 1);
393    }
394
395    #[test]
396    fn test_sibling_relative_import() {
397        let parsed = parse_file("import .customer;").unwrap();
398        assert_eq!(parsed.imports[0].raw_path, "customer");
399        assert_eq!(parsed.imports[0].parent_dirs, 0);
400    }
401
402    #[test]
403    fn test_stdlib_import() {
404        let parsed = parse_file("import std.aggregates;").unwrap();
405        assert!(parsed.imports[0].is_stdlib);
406    }
407
408    #[test]
409    fn test_datasource_simple() {
410        let content = r#"
411            key order_id int;
412            datasource orders (
413                order_id: order_id,
414                amount: amount
415            )
416            grain (order_id)
417            address my_database.orders;
418        "#;
419        let parsed = parse_file(content).unwrap();
420        assert_eq!(parsed.datasources.len(), 1);
421        let ds = &parsed.datasources[0];
422        assert_eq!(ds.name, "orders");
423        assert_eq!(ds.address.as_deref(), Some("my_database.orders"));
424        assert_eq!(ds.address_kind, AddressKind::Literal);
425        assert!(!ds.is_root);
426        assert!(!ds.is_partitioned);
427    }
428
429    #[test]
430    fn test_datasource_with_quoted_address() {
431        let content = r#"
432            key customer_id int;
433            datasource customers (
434                id: customer_id,
435                name: customer_name
436            )
437            grain (customer_id)
438            address `my_db.customers`;
439        "#;
440        let parsed = parse_file(content).unwrap();
441        assert_eq!(parsed.datasources.len(), 1);
442        let ds = &parsed.datasources[0];
443        assert_eq!(ds.name, "customers");
444        assert_eq!(ds.address.as_deref(), Some("my_db.customers"));
445        assert_eq!(ds.address_kind, AddressKind::Literal);
446    }
447
448    #[test]
449    fn test_root_partitioned_datasource() {
450        let content = r#"
451            key event_id int;
452            root datasource events (
453                event_id: event_id
454            )
455            grain (event_id)
456            address analytics.events
457            partition by event_id;
458        "#;
459        let parsed = parse_file(content).unwrap();
460        let ds = &parsed.datasources[0];
461        assert!(ds.is_root);
462        assert!(ds.is_partitioned);
463        assert_eq!(ds.address.as_deref(), Some("analytics.events"));
464    }
465
466    #[test]
467    fn test_templated_address_datasource() {
468        let content = r#"
469            key order_id int;
470            datasource orders (
471                order_id: order_id
472            )
473            grain (order_id)
474            address f`{{env}}.orders`;
475        "#;
476        let parsed = parse_file(content).unwrap();
477        let ds = &parsed.datasources[0];
478        assert_eq!(ds.address_kind, AddressKind::Templated);
479        // Raw template body is kept — it cannot be resolved statically.
480        assert_eq!(ds.address.as_deref(), Some("{{env}}.orders"));
481    }
482
483    #[test]
484    fn test_query_datasource_has_no_address() {
485        let content = r#"
486            key order_id int;
487            datasource order_view (
488                order_id: order_id
489            )
490            grain (order_id)
491            query '''select 1 as order_id''';
492        "#;
493        let parsed = parse_file(content).unwrap();
494        let ds = &parsed.datasources[0];
495        assert_eq!(ds.address_kind, AddressKind::Query);
496        assert!(ds.address.is_none());
497    }
498
499    #[test]
500    fn test_file_datasource() {
501        let content = r#"
502            key launch_id int;
503            datasource launches (
504                launch_id: launch_id
505            )
506            grain (launch_id)
507            file `gcs://bucket/launch_report/launch.parquet`;
508        "#;
509        let parsed = parse_file(content).unwrap();
510        let ds = &parsed.datasources[0];
511        assert_eq!(ds.address_kind, AddressKind::File);
512        // Raw spec: file paths may themselves contain `:` (`gcs://`), so the
513        // backticked form is kept verbatim rather than unquoted ambiguously.
514        assert_eq!(
515            ds.address.as_deref(),
516            Some("`gcs://bucket/launch_report/launch.parquet`")
517        );
518    }
519
520    #[test]
521    fn test_file_datasource_read_write_pair() {
522        let content = r#"
523            key launch_id int;
524            datasource launches (
525                launch_id: launch_id
526            )
527            grain (launch_id)
528            file `https://host/launch.parquet`:`gcs://bucket/launch.parquet`;
529        "#;
530        let parsed = parse_file(content).unwrap();
531        let ds = &parsed.datasources[0];
532        assert_eq!(ds.address_kind, AddressKind::File);
533        assert_eq!(
534            ds.address.as_deref(),
535            Some("`https://host/launch.parquet`:`gcs://bucket/launch.parquet`")
536        );
537    }
538
539    #[test]
540    fn test_root_partial_file_datasource() {
541        let content = r#"
542            key a_id int;
543            root partial datasource a_raw (
544                a_id: a_id
545            )
546            grain (a_id)
547            file `./a_raw_source.py`;
548        "#;
549        let parsed = parse_file(content).unwrap();
550        let ds = &parsed.datasources[0];
551        assert_eq!(ds.name, "a_raw");
552        assert!(ds.is_root);
553        assert!(ds.is_partial);
554        assert!(!ds.is_partitioned);
555        assert_eq!(ds.address_kind, AddressKind::File);
556    }
557
558    #[test]
559    fn test_partial_datasource_name_not_shadowed_by_modifier() {
560        let content = r#"
561            key customer_id int;
562            partial datasource customer_revenue (
563                customer_id: customer_id
564            )
565            grain (customer_id)
566            address db.customer_revenue;
567        "#;
568        let parsed = parse_file(content).unwrap();
569        let ds = &parsed.datasources[0];
570        assert_eq!(ds.name, "customer_revenue");
571        assert!(ds.is_partial);
572        assert!(!ds.is_root);
573    }
574
575    #[test]
576    fn test_multiple_datasources_keep_independent_flags() {
577        let content = r#"
578            key order_id int;
579            root datasource raw_orders (
580                order_id: order_id
581            )
582            grain (order_id)
583            address raw.orders;
584
585            datasource orders (
586                order_id: order_id
587            )
588            grain (order_id)
589            address db.orders;
590        "#;
591        let parsed = parse_file(content).unwrap();
592        assert_eq!(parsed.datasources.len(), 2);
593        assert!(parsed.datasources[0].is_root);
594        assert_eq!(parsed.datasources[0].address.as_deref(), Some("raw.orders"));
595        assert!(!parsed.datasources[1].is_root);
596        assert_eq!(parsed.datasources[1].address.as_deref(), Some("db.orders"));
597    }
598
599    #[test]
600    fn test_quoted_address_strips_inner_quote_layer() {
601        let content = r#"
602            key order_id int;
603            datasource orders (
604                order_id: order_id
605            )
606            grain (order_id)
607            address `'my project.my dataset.orders'`;
608        "#;
609        let parsed = parse_file(content).unwrap();
610        let ds = &parsed.datasources[0];
611        assert_eq!(ds.address_kind, AddressKind::Literal);
612        assert_eq!(
613            ds.address.as_deref(),
614            Some("my project.my dataset.orders")
615        );
616    }
617
618    #[test]
619    fn test_address_kind_strings_are_stable() {
620        // These are the serialized `address_kind` values in CLI JSON output.
621        assert_eq!(AddressKind::Literal.to_string(), "literal");
622        assert_eq!(AddressKind::Templated.to_string(), "templated");
623        assert_eq!(AddressKind::Query.to_string(), "query");
624        assert_eq!(AddressKind::File.to_string(), "file");
625    }
626
627    #[test]
628    fn test_selective_import_creates_edge() {
629        let parsed = parse_file("from models.customer import customer_id;").unwrap();
630        assert_eq!(parsed.imports.len(), 1);
631        assert_eq!(parsed.imports[0].raw_path, "models.customer");
632        assert!(parsed.imports[0].alias.is_none());
633    }
634
635    #[test]
636    fn test_selective_import_with_alias() {
637        let parsed = parse_file("from ..models.customer as cust import customer_id, name;").unwrap();
638        assert_eq!(parsed.imports.len(), 1);
639        assert_eq!(parsed.imports[0].raw_path, "models.customer");
640        assert_eq!(parsed.imports[0].alias, Some("cust".to_string()));
641        assert_eq!(parsed.imports[0].parent_dirs, 1);
642    }
643
644    #[test]
645    fn test_self_import_is_not_an_edge() {
646        let parsed = parse_file("self import as me;").unwrap();
647        assert!(parsed.imports.is_empty());
648    }
649
650    #[test]
651    fn test_auto_persist() {
652        let parsed = parse_file("persist orders;").unwrap();
653        assert_eq!(parsed.persists.len(), 1);
654        assert_eq!(parsed.persists[0].target_datasource, "orders");
655        assert_eq!(parsed.persists[0].mode, PersistMode::Persist);
656    }
657
658    #[test]
659    fn test_append_auto_persist() {
660        let parsed = parse_file("append orders;").unwrap();
661        assert_eq!(parsed.persists.len(), 1);
662        assert_eq!(parsed.persists[0].target_datasource, "orders");
663        assert_eq!(parsed.persists[0].mode, PersistMode::Append);
664    }
665
666    #[test]
667    fn test_full_persist() {
668        let content = r#"
669            key order_id int;
670            overwrite into target_orders from select order_id;
671        "#;
672        let parsed = parse_file(content).unwrap();
673        assert_eq!(parsed.persists.len(), 1);
674        assert_eq!(parsed.persists[0].target_datasource, "target_orders");
675        assert_eq!(parsed.persists[0].mode, PersistMode::Overwrite);
676    }
677
678    #[test]
679    fn test_multiple_imports() {
680        let content = r#"
681            import models.customer;
682            import models.orders as ord;
683            // comment
684            import ..shared.utils;
685        "#;
686        let parsed = parse_file(content).unwrap();
687        assert_eq!(parsed.imports.len(), 3);
688        assert_eq!(parsed.imports[1].alias, Some("ord".to_string()));
689        assert_eq!(parsed.imports[2].parent_dirs, 1);
690    }
691
692    #[test]
693    fn test_mixed_file() {
694        let content = r#"
695            import models.customer;
696
697            key order_id int;
698            datasource local_orders (
699                order_id: order_id
700            )
701            grain (order_id)
702            address local.orders;
703
704            persist local_orders;
705        "#;
706        let parsed = parse_file(content).unwrap();
707        assert_eq!(parsed.imports.len(), 1);
708        assert_eq!(parsed.datasources.len(), 1);
709        assert_eq!(parsed.persists.len(), 1);
710        assert_eq!(parsed.datasources[0].name, "local_orders");
711        assert_eq!(parsed.persists[0].target_datasource, "local_orders");
712    }
713}