Skip to main content

pgevolve_core/parse/
directives.rs

1//! `-- @pgevolve` file-level directives.
2//!
3//! Directives are SQL line-comments of the form
4//!
5//! ```text
6//! -- @pgevolve <key>=<value>[ <key>=<value>...]
7//! ```
8//!
9//! In phase 2 the only source-side directive is `schema=<name>`, which sets the
10//! default schema for unqualified object names within the file. Plan-format
11//! directives (`step=`, `group=`, etc.) are emitted by the planner; we recognize
12//! and ignore them here so that round-tripping a plan back through the parser
13//! does not error.
14//!
15//! Additionally, `dep:` directives (Decision 11) declare explicit dependencies
16//! for PL/pgSQL bodies that reference objects via dynamic SQL:
17//!
18//! ```text
19//! -- @pgevolve dep: schema.object_name
20//! ```
21
22use std::path::Path;
23
24use crate::identifier::Identifier;
25use crate::parse::error::{ParseError, SourceLocation};
26
27/// A `-- @pgevolve dep: <qualified-name>` directive.
28///
29/// Closes the PL/pgSQL dynamic-SQL gap (Decision 11). For v0.1 the
30/// recognizer parses these but no consumer reads them yet; v0.2
31/// function sub-spec will populate `AstDeclared` edges from them.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct DepDirective {
34    /// Fully qualified target. Stored verbatim; the consumer resolves.
35    pub target: String,
36}
37
38/// Directives extracted from a single source file's leading comment block.
39#[derive(Debug, Clone, Default)]
40pub struct FileDirectives {
41    /// Default schema for unqualified object names in this file.
42    pub schema: Option<Identifier>,
43    /// Explicit dependency declarations for dynamic-SQL bodies.
44    pub deps: Vec<DepDirective>,
45}
46
47/// Plan-format directive keys that are silently ignored when reading source SQL.
48///
49/// These are emitted by the planner in later phases; the parser must accept them
50/// without complaint so that plans round-trip.
51const IGNORED_PLAN_KEYS: &[&str] = &[
52    "plan",
53    "step",
54    "group",
55    "kind",
56    "destructive",
57    "intent_id",
58    "version",
59    "created",
60    "source_rev",
61    "target",
62    "intents_required",
63    "transactional",
64    "targets",
65];
66
67/// Scan the file's leading comment block for `-- @pgevolve` directives.
68///
69/// Scanning stops at the first non-empty, non-comment line — directives must
70/// appear before any SQL.
71pub fn extract_file_directives(sql: &str, file: &Path) -> Result<FileDirectives, ParseError> {
72    let mut out = FileDirectives::default();
73    for (line_no, raw_line) in sql.lines().enumerate() {
74        let trimmed = raw_line.trim();
75        if trimmed.is_empty() {
76            continue;
77        }
78        let Some(rest) = trimmed.strip_prefix("--") else {
79            break;
80        };
81        let rest = rest.trim();
82        let Some(payload) = rest.strip_prefix("@pgevolve") else {
83            continue;
84        };
85        let payload = payload.trim();
86        // `dep:` is a special form that takes the rest of the line as its
87        // target value, rather than using the `key=value` tokenization.
88        if let Some(target_raw) = payload.strip_prefix("dep:") {
89            let target = target_raw.trim().to_string();
90            if target.is_empty() {
91                return Err(ParseError::InvalidDirective {
92                    location: SourceLocation::new(file.into(), line_no + 1, 1),
93                    message: "dep: requires a non-empty target".into(),
94                });
95            }
96            out.deps.push(DepDirective { target });
97            continue;
98        }
99        for kv in payload.split_whitespace() {
100            let Some((k, v)) = kv.split_once('=') else {
101                return Err(ParseError::InvalidDirective {
102                    location: SourceLocation::new(file.into(), line_no + 1, 1),
103                    message: format!("expected `key=value`, got {kv:?}"),
104                });
105            };
106            apply_kv(&mut out, k, v, file, line_no)?;
107        }
108    }
109    Ok(out)
110}
111
112fn apply_kv(
113    out: &mut FileDirectives,
114    key: &str,
115    value: &str,
116    file: &Path,
117    line_no: usize,
118) -> Result<(), ParseError> {
119    match key {
120        "schema" => {
121            if value.is_empty() {
122                return Err(ParseError::InvalidDirective {
123                    location: SourceLocation::new(file.into(), line_no + 1, 1),
124                    message: "schema= requires a non-empty identifier".into(),
125                });
126            }
127            let id =
128                Identifier::from_unquoted(value).map_err(|e| ParseError::InvalidDirective {
129                    location: SourceLocation::new(file.into(), line_no + 1, 1),
130                    message: format!("invalid schema identifier: {e}"),
131                })?;
132            out.schema = Some(id);
133            Ok(())
134        }
135        k if IGNORED_PLAN_KEYS.contains(&k) => Ok(()),
136        other => Err(ParseError::InvalidDirective {
137            location: SourceLocation::new(file.into(), line_no + 1, 1),
138            message: format!("unknown directive key: {other}"),
139        }),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::path::PathBuf;
147
148    fn parse(sql: &str) -> Result<FileDirectives, ParseError> {
149        extract_file_directives(sql, &PathBuf::from("test.sql"))
150    }
151
152    #[test]
153    fn schema_directive_recognized() {
154        let d = parse("-- @pgevolve schema=app\nCREATE TABLE x (id int);").unwrap();
155        assert_eq!(
156            d.schema.as_ref().map(crate::identifier::Identifier::as_str),
157            Some("app")
158        );
159    }
160
161    #[test]
162    fn schema_directive_lowercases() {
163        let d = parse("-- @pgevolve schema=Foo\n").unwrap();
164        assert_eq!(
165            d.schema.as_ref().map(crate::identifier::Identifier::as_str),
166            Some("foo")
167        );
168    }
169
170    #[test]
171    fn directive_in_multi_line_header() {
172        let sql = "\
173            -- header comment\n\
174            -- another comment\n\
175            -- @pgevolve schema=billing\n\
176            CREATE TABLE x (id int);\n";
177        let d = parse(sql).unwrap();
178        assert_eq!(
179            d.schema.as_ref().map(crate::identifier::Identifier::as_str),
180            Some("billing")
181        );
182    }
183
184    #[test]
185    fn directive_after_first_sql_is_ignored() {
186        let sql = "\
187            CREATE TABLE x (id int);\n\
188            -- @pgevolve schema=app\n";
189        let d = parse(sql).unwrap();
190        assert!(d.schema.is_none());
191    }
192
193    #[test]
194    fn empty_value_rejected() {
195        let err = parse("-- @pgevolve schema=\n").unwrap_err();
196        assert!(matches!(err, ParseError::InvalidDirective { .. }));
197    }
198
199    #[test]
200    fn missing_equals_rejected() {
201        let err = parse("-- @pgevolve schema\n").unwrap_err();
202        match err {
203            ParseError::InvalidDirective { message, .. } => {
204                assert!(message.contains("key=value"), "got: {message}");
205            }
206            other => panic!("expected InvalidDirective, got {other:?}"),
207        }
208    }
209
210    #[test]
211    fn unknown_key_rejected() {
212        let err = parse("-- @pgevolve unknown=x\n").unwrap_err();
213        match err {
214            ParseError::InvalidDirective { message, .. } => {
215                assert!(message.contains("unknown"), "got: {message}");
216            }
217            other => panic!("expected InvalidDirective, got {other:?}"),
218        }
219    }
220
221    #[test]
222    fn plan_format_keys_silently_ignored() {
223        let d = parse("-- @pgevolve schema=app step=1 group=migrations kind=create\n").unwrap();
224        assert_eq!(
225            d.schema.as_ref().map(crate::identifier::Identifier::as_str),
226            Some("app")
227        );
228    }
229
230    #[test]
231    fn empty_lines_in_header_are_skipped() {
232        let sql = "\n\n-- @pgevolve schema=app\nCREATE TABLE x (id int);\n";
233        let d = parse(sql).unwrap();
234        assert_eq!(
235            d.schema.as_ref().map(crate::identifier::Identifier::as_str),
236            Some("app")
237        );
238    }
239
240    #[test]
241    fn no_directive_returns_default() {
242        let d = parse("-- just a comment\nCREATE TABLE x (id int);\n").unwrap();
243        assert!(d.schema.is_none());
244    }
245
246    // --- dep: directive tests ---
247
248    #[test]
249    fn dep_directive_parses() {
250        let d = parse("-- @pgevolve dep: app.audit_log\n").unwrap();
251        assert_eq!(d.deps.len(), 1);
252        assert_eq!(d.deps[0].target, "app.audit_log");
253    }
254
255    #[test]
256    fn dep_directive_trims_whitespace() {
257        let d = parse("-- @pgevolve dep:   app.users.email   \n").unwrap();
258        assert_eq!(d.deps.len(), 1);
259        assert_eq!(d.deps[0].target, "app.users.email");
260    }
261
262    #[test]
263    fn multiple_dep_directives_collected() {
264        let sql = "-- @pgevolve dep: app.foo\n-- @pgevolve dep: app.bar\nSELECT 1;\n";
265        let d = parse(sql).unwrap();
266        let targets: Vec<&str> = d.deps.iter().map(|dep| dep.target.as_str()).collect();
267        assert!(
268            targets.contains(&"app.foo"),
269            "expected app.foo in {targets:?}"
270        );
271        assert!(
272            targets.contains(&"app.bar"),
273            "expected app.bar in {targets:?}"
274        );
275    }
276
277    #[test]
278    fn dep_directive_coexists_with_schema_directive() {
279        let sql = "-- @pgevolve schema=app\n-- @pgevolve dep: app.audit_log\n";
280        let d = parse(sql).unwrap();
281        assert_eq!(d.schema.as_ref().map(Identifier::as_str), Some("app"));
282        assert_eq!(d.deps.len(), 1);
283        assert_eq!(d.deps[0].target, "app.audit_log");
284    }
285
286    #[test]
287    fn dep_after_first_sql_is_ignored() {
288        // Directives after the first non-comment SQL line are not scanned.
289        let sql = "SELECT 1;\n-- @pgevolve dep: app.foo\n";
290        let d = parse(sql).unwrap();
291        assert!(d.deps.is_empty());
292    }
293
294    #[test]
295    fn dep_directive_with_empty_target_is_rejected() {
296        let err = parse("-- @pgevolve dep:\n").unwrap_err();
297        match err {
298            ParseError::InvalidDirective { message, .. } => {
299                assert!(message.contains("non-empty target"), "got: {message}");
300            }
301            other => panic!("expected InvalidDirective, got {other:?}"),
302        }
303    }
304}