1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum AddressKind {
54 Literal,
56 Templated,
58 Query,
60 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 pub address: Option<String>,
87 pub address_kind: AddressKind,
88 pub is_root: bool,
90 pub is_partial: bool,
93 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 for stmt in top.into_inner() {
157 match stmt.as_rule() {
158 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
184fn 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 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 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
228fn 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 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 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 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 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 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
313fn 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
335fn 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 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 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 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}