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 if let Some(declaration) = extract_datasource(stmt)? {
168 result.datasources.push(declaration);
169 }
170 }
171 Rule::persist_statement => {
172 result.persists.push(extract_persist(stmt)?);
173 }
174 _ => {}
175 }
176 }
177 }
178
179 Ok(result)
180}
181
182pub fn parse_imports(content: &str) -> Result<Vec<ImportStatement>, ParseError> {
183 Ok(parse_file(content)?.imports)
184}
185
186fn extract_import(pair: Pair<Rule>) -> Result<ImportStatement, ParseError> {
190 let full_text = pair.as_str();
191 let mut n_dots = 0usize;
192 let mut idents: Vec<String> = Vec::new();
193 for child in pair.into_inner() {
194 match child.as_rule() {
195 Rule::IMPORT_DOT => n_dots += 1,
196 Rule::IDENTIFIER => idents.push(child.as_str().to_string()),
197 _ => {}
198 }
199 }
200 if idents.is_empty() {
201 return Err(ParseError::InvalidImportStructure);
202 }
203
204 let has_alias = full_text
207 .split_ascii_whitespace()
208 .any(|tok| tok.eq_ignore_ascii_case("as"));
209 let alias = if has_alias && idents.len() >= 2 {
210 Some(idents.pop().unwrap())
211 } else {
212 None
213 };
214
215 let raw_path = idents.join(".");
216 let is_stdlib = raw_path == "std" || raw_path.starts_with("std.");
217 let parent_dirs = n_dots.saturating_sub(1);
221
222 Ok(ImportStatement {
223 raw_path,
224 parent_dirs,
225 alias,
226 is_stdlib,
227 })
228}
229
230fn extract_datasource(pair: Pair<Rule>) -> Result<Option<DatasourceDeclaration>, ParseError> {
236 let mut name: Option<String> = None;
237 let mut address: Option<String> = None;
238 let mut address_kind: Option<AddressKind> = None;
239 let mut is_root = false;
240 let mut is_template = false;
241 let mut is_partial = false;
242 let mut is_partitioned = false;
243
244 for child in pair.into_inner() {
245 match child.as_rule() {
246 Rule::DATASOURCE_ROOT => is_root = true,
247 Rule::DATASOURCE_TEMPLATE => is_template = true,
248 Rule::DATASOURCE_PARTIAL => is_partial = true,
249 Rule::IDENTIFIER if name.is_none() => name = Some(child.as_str().to_string()),
250 Rule::address => {
252 let tok = child
253 .into_inner()
254 .next()
255 .ok_or(ParseError::InvalidDatasourceStructure)?;
256 match tok.as_rule() {
257 Rule::F_QUOTED_ADDRESS => {
258 address_kind = Some(AddressKind::Templated);
261 address = Some(
262 tok.as_str()
263 .trim_start_matches(['f', 'F'])
264 .trim_matches('`')
265 .to_string(),
266 );
267 }
268 Rule::QUOTED_ADDRESS => {
269 address_kind = Some(AddressKind::Literal);
271 address = Some(
272 tok.as_str().trim_matches('`').trim_matches('\'').to_string(),
273 );
274 }
275 _ => {
276 address_kind = Some(AddressKind::Literal);
277 address = Some(tok.as_str().to_string());
278 }
279 }
280 }
281 Rule::query => address_kind = Some(AddressKind::Query),
282 Rule::file => {
283 address_kind = Some(AddressKind::File);
284 address = Some(child.as_str()[4..].trim().to_string());
286 }
287 Rule::datasource_partition_clause => is_partitioned = true,
288 _ => {}
289 }
290 }
291
292 if is_template {
293 return Ok(None);
294 }
295 match (name, address_kind) {
296 (Some(name), Some(address_kind)) => Ok(Some(DatasourceDeclaration {
297 name,
298 address,
299 address_kind,
300 is_root,
301 is_partial,
302 is_partitioned,
303 })),
304 _ => Err(ParseError::InvalidDatasourceStructure),
305 }
306}
307
308fn extract_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
309 let inner = pair
311 .into_inner()
312 .next()
313 .ok_or(ParseError::InvalidPersistStructure)?;
314 match inner.as_rule() {
315 Rule::auto_persist => extract_auto_persist(inner),
316 Rule::full_persist => extract_full_persist(inner),
317 _ => Err(ParseError::InvalidPersistStructure),
318 }
319}
320
321fn extract_auto_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
323 let mut mode: Option<PersistMode> = None;
324 let mut target: Option<String> = None;
325 for child in pair.into_inner() {
326 match child.as_rule() {
327 Rule::PERSIST_MODE => mode = Some(parse_persist_mode(child.as_str())),
328 Rule::IDENTIFIER if target.is_none() => {
329 target = Some(child.as_str().to_string());
330 }
331 _ => {}
332 }
333 }
334 match (mode, target) {
335 (Some(mode), Some(target_datasource)) => Ok(PersistStatement {
336 mode,
337 target_datasource,
338 }),
339 _ => Err(ParseError::InvalidPersistStructure),
340 }
341}
342
343fn extract_full_persist(pair: Pair<Rule>) -> Result<PersistStatement, ParseError> {
348 let mut mode: Option<PersistMode> = None;
349 let mut last_ident: Option<String> = None;
350 for child in pair.into_inner() {
351 match child.as_rule() {
352 Rule::PERSIST_MODE => mode = Some(parse_persist_mode(child.as_str())),
353 Rule::IDENTIFIER => last_ident = Some(child.as_str().to_string()),
354 _ => {}
355 }
356 }
357 match (mode, last_ident) {
358 (Some(mode), Some(target_datasource)) => Ok(PersistStatement {
359 mode,
360 target_datasource,
361 }),
362 _ => Err(ParseError::InvalidPersistStructure),
363 }
364}
365
366fn parse_persist_mode(s: &str) -> PersistMode {
367 match s.to_ascii_lowercase().as_str() {
368 "append" => PersistMode::Append,
369 "overwrite" => PersistMode::Overwrite,
370 _ => PersistMode::Persist,
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn test_simple_import() {
380 let parsed = parse_file("import models.customer;").unwrap();
381 assert_eq!(parsed.imports.len(), 1);
382 assert_eq!(parsed.imports[0].raw_path, "models.customer");
383 assert_eq!(parsed.imports[0].parent_dirs, 0);
384 assert!(parsed.imports[0].alias.is_none());
385 }
386
387 #[test]
388 fn test_import_with_alias() {
389 let parsed = parse_file("import models.customer as cust;").unwrap();
390 assert_eq!(parsed.imports.len(), 1);
391 assert_eq!(parsed.imports[0].raw_path, "models.customer");
392 assert_eq!(parsed.imports[0].alias, Some("cust".to_string()));
393 }
394
395 #[test]
396 fn test_relative_import() {
397 let parsed = parse_file("import ..models.customer;").unwrap();
398 assert_eq!(parsed.imports.len(), 1);
399 assert_eq!(parsed.imports[0].raw_path, "models.customer");
400 assert_eq!(parsed.imports[0].parent_dirs, 1);
401 }
402
403 #[test]
404 fn test_sibling_relative_import() {
405 let parsed = parse_file("import .customer;").unwrap();
406 assert_eq!(parsed.imports[0].raw_path, "customer");
407 assert_eq!(parsed.imports[0].parent_dirs, 0);
408 }
409
410 #[test]
411 fn test_stdlib_import() {
412 let parsed = parse_file("import std.aggregates;").unwrap();
413 assert!(parsed.imports[0].is_stdlib);
414 }
415
416 #[test]
417 fn test_datasource_simple() {
418 let content = r#"
419 key order_id int;
420 datasource orders (
421 order_id: order_id,
422 amount: amount
423 )
424 grain (order_id)
425 address my_database.orders;
426 "#;
427 let parsed = parse_file(content).unwrap();
428 assert_eq!(parsed.datasources.len(), 1);
429 let ds = &parsed.datasources[0];
430 assert_eq!(ds.name, "orders");
431 assert_eq!(ds.address.as_deref(), Some("my_database.orders"));
432 assert_eq!(ds.address_kind, AddressKind::Literal);
433 assert!(!ds.is_root);
434 assert!(!ds.is_partitioned);
435 }
436
437 #[test]
438 fn test_datasource_with_quoted_address() {
439 let content = r#"
440 key customer_id int;
441 datasource customers (
442 id: customer_id,
443 name: customer_name
444 )
445 grain (customer_id)
446 address `my_db.customers`;
447 "#;
448 let parsed = parse_file(content).unwrap();
449 assert_eq!(parsed.datasources.len(), 1);
450 let ds = &parsed.datasources[0];
451 assert_eq!(ds.name, "customers");
452 assert_eq!(ds.address.as_deref(), Some("my_db.customers"));
453 assert_eq!(ds.address_kind, AddressKind::Literal);
454 }
455
456 #[test]
457 fn test_root_partitioned_datasource() {
458 let content = r#"
459 key event_id int;
460 root datasource events (
461 event_id: event_id
462 )
463 grain (event_id)
464 address analytics.events
465 partition by event_id;
466 "#;
467 let parsed = parse_file(content).unwrap();
468 let ds = &parsed.datasources[0];
469 assert!(ds.is_root);
470 assert!(ds.is_partitioned);
471 assert_eq!(ds.address.as_deref(), Some("analytics.events"));
472 }
473
474 #[test]
475 fn test_templated_address_datasource() {
476 let content = r#"
477 key order_id int;
478 datasource orders (
479 order_id: order_id
480 )
481 grain (order_id)
482 address f`{{env}}.orders`;
483 "#;
484 let parsed = parse_file(content).unwrap();
485 let ds = &parsed.datasources[0];
486 assert_eq!(ds.address_kind, AddressKind::Templated);
487 assert_eq!(ds.address.as_deref(), Some("{{env}}.orders"));
489 }
490
491 #[test]
492 fn test_query_datasource_has_no_address() {
493 let content = r#"
494 key order_id int;
495 datasource order_view (
496 order_id: order_id
497 )
498 grain (order_id)
499 query '''select 1 as order_id''';
500 "#;
501 let parsed = parse_file(content).unwrap();
502 let ds = &parsed.datasources[0];
503 assert_eq!(ds.address_kind, AddressKind::Query);
504 assert!(ds.address.is_none());
505 }
506
507 #[test]
508 fn test_file_datasource() {
509 let content = r#"
510 key launch_id int;
511 datasource launches (
512 launch_id: launch_id
513 )
514 grain (launch_id)
515 file `gcs://bucket/launch_report/launch.parquet`;
516 "#;
517 let parsed = parse_file(content).unwrap();
518 let ds = &parsed.datasources[0];
519 assert_eq!(ds.address_kind, AddressKind::File);
520 assert_eq!(
523 ds.address.as_deref(),
524 Some("`gcs://bucket/launch_report/launch.parquet`")
525 );
526 }
527
528 #[test]
529 fn test_file_datasource_read_write_pair() {
530 let content = r#"
531 key launch_id int;
532 datasource launches (
533 launch_id: launch_id
534 )
535 grain (launch_id)
536 file `https://host/launch.parquet`:`gcs://bucket/launch.parquet`;
537 "#;
538 let parsed = parse_file(content).unwrap();
539 let ds = &parsed.datasources[0];
540 assert_eq!(ds.address_kind, AddressKind::File);
541 assert_eq!(
542 ds.address.as_deref(),
543 Some("`https://host/launch.parquet`:`gcs://bucket/launch.parquet`")
544 );
545 }
546
547 #[test]
548 fn test_root_partial_file_datasource() {
549 let content = r#"
550 key a_id int;
551 root partial datasource a_raw (
552 a_id: a_id
553 )
554 grain (a_id)
555 file `./a_raw_source.py`;
556 "#;
557 let parsed = parse_file(content).unwrap();
558 let ds = &parsed.datasources[0];
559 assert_eq!(ds.name, "a_raw");
560 assert!(ds.is_root);
561 assert!(ds.is_partial);
562 assert!(!ds.is_partitioned);
563 assert_eq!(ds.address_kind, AddressKind::File);
564 }
565
566 #[test]
567 fn test_partial_datasource_name_not_shadowed_by_modifier() {
568 let content = r#"
569 key customer_id int;
570 partial datasource customer_revenue (
571 customer_id: customer_id
572 )
573 grain (customer_id)
574 address db.customer_revenue;
575 "#;
576 let parsed = parse_file(content).unwrap();
577 let ds = &parsed.datasources[0];
578 assert_eq!(ds.name, "customer_revenue");
579 assert!(ds.is_partial);
580 assert!(!ds.is_root);
581 }
582
583 #[test]
584 fn test_multiple_datasources_keep_independent_flags() {
585 let content = r#"
586 key order_id int;
587 root datasource raw_orders (
588 order_id: order_id
589 )
590 grain (order_id)
591 address raw.orders;
592
593 datasource orders (
594 order_id: order_id
595 )
596 grain (order_id)
597 address db.orders;
598 "#;
599 let parsed = parse_file(content).unwrap();
600 assert_eq!(parsed.datasources.len(), 2);
601 assert!(parsed.datasources[0].is_root);
602 assert_eq!(parsed.datasources[0].address.as_deref(), Some("raw.orders"));
603 assert!(!parsed.datasources[1].is_root);
604 assert_eq!(parsed.datasources[1].address.as_deref(), Some("db.orders"));
605 }
606
607 #[test]
608 fn test_quoted_address_strips_inner_quote_layer() {
609 let content = r#"
610 key order_id int;
611 datasource orders (
612 order_id: order_id
613 )
614 grain (order_id)
615 address `'my project.my dataset.orders'`;
616 "#;
617 let parsed = parse_file(content).unwrap();
618 let ds = &parsed.datasources[0];
619 assert_eq!(ds.address_kind, AddressKind::Literal);
620 assert_eq!(
621 ds.address.as_deref(),
622 Some("my project.my dataset.orders")
623 );
624 }
625
626 #[test]
627 fn test_address_kind_strings_are_stable() {
628 assert_eq!(AddressKind::Literal.to_string(), "literal");
630 assert_eq!(AddressKind::Templated.to_string(), "templated");
631 assert_eq!(AddressKind::Query.to_string(), "query");
632 assert_eq!(AddressKind::File.to_string(), "file");
633 }
634
635 #[test]
636 fn test_selective_import_creates_edge() {
637 let parsed = parse_file("from models.customer import customer_id;").unwrap();
638 assert_eq!(parsed.imports.len(), 1);
639 assert_eq!(parsed.imports[0].raw_path, "models.customer");
640 assert!(parsed.imports[0].alias.is_none());
641 }
642
643 #[test]
644 fn test_selective_import_with_alias() {
645 let parsed = parse_file("from ..models.customer as cust import customer_id, name;").unwrap();
646 assert_eq!(parsed.imports.len(), 1);
647 assert_eq!(parsed.imports[0].raw_path, "models.customer");
648 assert_eq!(parsed.imports[0].alias, Some("cust".to_string()));
649 assert_eq!(parsed.imports[0].parent_dirs, 1);
650 }
651
652 #[test]
653 fn test_self_import_is_not_an_edge() {
654 let parsed = parse_file("self import as me;").unwrap();
655 assert!(parsed.imports.is_empty());
656 }
657
658 #[test]
659 fn test_auto_persist() {
660 let parsed = parse_file("persist 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::Persist);
664 }
665
666 #[test]
667 fn test_append_auto_persist() {
668 let parsed = parse_file("append orders;").unwrap();
669 assert_eq!(parsed.persists.len(), 1);
670 assert_eq!(parsed.persists[0].target_datasource, "orders");
671 assert_eq!(parsed.persists[0].mode, PersistMode::Append);
672 }
673
674 #[test]
675 fn test_full_persist() {
676 let content = r#"
677 key order_id int;
678 overwrite into target_orders from select order_id;
679 "#;
680 let parsed = parse_file(content).unwrap();
681 assert_eq!(parsed.persists.len(), 1);
682 assert_eq!(parsed.persists[0].target_datasource, "target_orders");
683 assert_eq!(parsed.persists[0].mode, PersistMode::Overwrite);
684 }
685
686 #[test]
687 fn test_multiple_imports() {
688 let content = r#"
689 import models.customer;
690 import models.orders as ord;
691 // comment
692 import ..shared.utils;
693 "#;
694 let parsed = parse_file(content).unwrap();
695 assert_eq!(parsed.imports.len(), 3);
696 assert_eq!(parsed.imports[1].alias, Some("ord".to_string()));
697 assert_eq!(parsed.imports[2].parent_dirs, 1);
698 }
699
700 #[test]
701 fn test_mixed_file() {
702 let content = r#"
703 import models.customer;
704
705 key order_id int;
706 datasource local_orders (
707 order_id: order_id
708 )
709 grain (order_id)
710 address local.orders;
711
712 persist local_orders;
713 "#;
714 let parsed = parse_file(content).unwrap();
715 assert_eq!(parsed.imports.len(), 1);
716 assert_eq!(parsed.datasources.len(), 1);
717 assert_eq!(parsed.persists.len(), 1);
718 assert_eq!(parsed.datasources[0].name, "local_orders");
719 assert_eq!(parsed.persists[0].target_datasource, "local_orders");
720 }
721}