Skip to main content

omnigraph_compiler/query/
lint.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use crate::catalog::Catalog;
6use crate::query::ast::{Mutation, QueryDecl};
7use crate::query::parser::parse_query;
8use crate::query::typecheck::typecheck_query_decl;
9
10const PARSE_ERROR_CODE: &str = "Q000";
11const L201_CODE: &str = "L201";
12const HARDCODED_MUTATION_WARNING: &str =
13    "mutation declares no params; hardcoded mutations are easy to miss";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "lowercase")]
17pub enum QueryLintStatus {
18    Ok,
19    Error,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum QueryLintSeverity {
25    Error,
26    Warning,
27    Info,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "lowercase")]
32pub enum QueryLintQueryKind {
33    Read,
34    Mutation,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum QueryLintSchemaSourceKind {
40    File,
41    Repo,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct QueryLintSchemaSource {
46    pub kind: QueryLintSchemaSourceKind,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub path: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub uri: Option<String>,
51}
52
53impl QueryLintSchemaSource {
54    pub fn file(path: impl Into<String>) -> Self {
55        Self {
56            kind: QueryLintSchemaSourceKind::File,
57            path: Some(path.into()),
58            uri: None,
59        }
60    }
61
62    pub fn repo(uri: impl Into<String>) -> Self {
63        Self {
64            kind: QueryLintSchemaSourceKind::Repo,
65            path: None,
66            uri: Some(uri.into()),
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct QueryLintQueryResult {
73    pub name: String,
74    pub kind: QueryLintQueryKind,
75    pub status: QueryLintStatus,
76    #[serde(skip_serializing_if = "Vec::is_empty", default)]
77    pub warnings: Vec<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub error: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct QueryLintFinding {
84    pub severity: QueryLintSeverity,
85    pub code: String,
86    pub message: String,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub type_name: Option<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub property: Option<String>,
91    #[serde(skip_serializing_if = "Vec::is_empty", default)]
92    pub query_names: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
96pub struct QueryLintOutput {
97    pub status: QueryLintStatus,
98    pub schema_source: QueryLintSchemaSource,
99    pub query_path: String,
100    pub queries_processed: usize,
101    pub errors: usize,
102    pub warnings: usize,
103    pub infos: usize,
104    pub results: Vec<QueryLintQueryResult>,
105    pub findings: Vec<QueryLintFinding>,
106}
107
108#[derive(Debug, Default)]
109struct UpdateCoverage {
110    query_names: BTreeSet<String>,
111    assigned_properties: BTreeSet<String>,
112}
113
114pub fn lint_query_file(
115    catalog: &Catalog,
116    query_source: &str,
117    query_path: impl Into<String>,
118    schema_source: QueryLintSchemaSource,
119) -> QueryLintOutput {
120    let query_path = query_path.into();
121    match parse_query(query_source) {
122        Ok(parsed) => {
123            let queries_processed = parsed.queries.len();
124            let mut results = Vec::with_capacity(queries_processed);
125            let mut coverage = BTreeMap::<String, UpdateCoverage>::new();
126
127            for query in &parsed.queries {
128                let kind = query_kind(query);
129                let warnings = per_query_warnings(query);
130                match typecheck_query_decl(catalog, query) {
131                    Ok(_) => {
132                        collect_update_coverage(query, &mut coverage);
133                        results.push(QueryLintQueryResult {
134                            name: query.name.clone(),
135                            kind,
136                            status: QueryLintStatus::Ok,
137                            warnings,
138                            error: None,
139                        });
140                    }
141                    Err(err) => {
142                        results.push(QueryLintQueryResult {
143                            name: query.name.clone(),
144                            kind,
145                            status: QueryLintStatus::Error,
146                            warnings,
147                            error: Some(err.to_string()),
148                        });
149                    }
150                }
151            }
152
153            let mut findings = lint_update_coverage(catalog, &coverage);
154            findings.sort_by(findings_cmp);
155
156            let errors = results
157                .iter()
158                .filter(|result| result.status == QueryLintStatus::Error)
159                .count()
160                + findings
161                    .iter()
162                    .filter(|finding| finding.severity == QueryLintSeverity::Error)
163                    .count();
164            let warnings = results
165                .iter()
166                .map(|result| result.warnings.len())
167                .sum::<usize>()
168                + findings
169                    .iter()
170                    .filter(|finding| finding.severity == QueryLintSeverity::Warning)
171                    .count();
172            let infos = findings
173                .iter()
174                .filter(|finding| finding.severity == QueryLintSeverity::Info)
175                .count();
176
177            QueryLintOutput {
178                status: if errors == 0 {
179                    QueryLintStatus::Ok
180                } else {
181                    QueryLintStatus::Error
182                },
183                schema_source,
184                query_path,
185                queries_processed,
186                errors,
187                warnings,
188                infos,
189                results,
190                findings,
191            }
192        }
193        Err(err) => QueryLintOutput {
194            status: QueryLintStatus::Error,
195            schema_source,
196            query_path,
197            queries_processed: 0,
198            errors: 1,
199            warnings: 0,
200            infos: 0,
201            results: Vec::new(),
202            findings: vec![QueryLintFinding {
203                severity: QueryLintSeverity::Error,
204                code: PARSE_ERROR_CODE.to_string(),
205                message: err.to_string(),
206                type_name: None,
207                property: None,
208                query_names: Vec::new(),
209            }],
210        },
211    }
212}
213
214fn query_kind(query: &QueryDecl) -> QueryLintQueryKind {
215    if query.mutations.is_empty() {
216        QueryLintQueryKind::Read
217    } else {
218        QueryLintQueryKind::Mutation
219    }
220}
221
222fn per_query_warnings(query: &QueryDecl) -> Vec<String> {
223    if query.mutations.is_empty() || !query.params.is_empty() {
224        return Vec::new();
225    }
226    vec![HARDCODED_MUTATION_WARNING.to_string()]
227}
228
229fn collect_update_coverage(query: &QueryDecl, coverage: &mut BTreeMap<String, UpdateCoverage>) {
230    for mutation in &query.mutations {
231        if let Mutation::Update(update) = mutation {
232            let entry = coverage.entry(update.type_name.clone()).or_default();
233            entry.query_names.insert(query.name.clone());
234            for assignment in &update.assignments {
235                entry
236                    .assigned_properties
237                    .insert(assignment.property.clone());
238            }
239        }
240    }
241}
242
243fn lint_update_coverage(
244    catalog: &Catalog,
245    coverage: &BTreeMap<String, UpdateCoverage>,
246) -> Vec<QueryLintFinding> {
247    let mut type_names = catalog.node_types.keys().cloned().collect::<Vec<_>>();
248    type_names.sort();
249
250    let mut findings = Vec::new();
251    for type_name in type_names {
252        let Some(type_coverage) = coverage.get(&type_name) else {
253            continue;
254        };
255        if type_coverage.query_names.is_empty() {
256            continue;
257        }
258
259        let node_type = &catalog.node_types[&type_name];
260        let key_properties = node_type.key.clone().unwrap_or_default();
261
262        let mut property_names = node_type.properties.keys().cloned().collect::<Vec<_>>();
263        property_names.sort();
264
265        for property_name in property_names {
266            let property = &node_type.properties[&property_name];
267            if !property.nullable {
268                continue;
269            }
270            if key_properties.iter().any(|key| key == &property_name) {
271                continue;
272            }
273            if node_type.embed_sources.contains_key(&property_name) {
274                continue;
275            }
276            if type_coverage.assigned_properties.contains(&property_name) {
277                continue;
278            }
279
280            findings.push(QueryLintFinding {
281                severity: QueryLintSeverity::Warning,
282                code: L201_CODE.to_string(),
283                message: format!(
284                    "{}.{} exists in schema but no update query sets it",
285                    type_name, property_name
286                ),
287                type_name: Some(type_name.clone()),
288                property: Some(property_name),
289                query_names: type_coverage.query_names.iter().cloned().collect(),
290            });
291        }
292    }
293    findings
294}
295
296fn findings_cmp(left: &QueryLintFinding, right: &QueryLintFinding) -> std::cmp::Ordering {
297    severity_rank(left.severity)
298        .cmp(&severity_rank(right.severity))
299        .then_with(|| left.type_name.cmp(&right.type_name))
300        .then_with(|| left.property.cmp(&right.property))
301        .then_with(|| left.message.cmp(&right.message))
302}
303
304fn severity_rank(severity: QueryLintSeverity) -> u8 {
305    match severity {
306        QueryLintSeverity::Error => 0,
307        QueryLintSeverity::Warning => 1,
308        QueryLintSeverity::Info => 2,
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::build_catalog;
316    use crate::schema::parser::parse_schema;
317
318    fn catalog(schema: &str) -> Catalog {
319        let schema = parse_schema(schema).unwrap();
320        build_catalog(&schema).unwrap()
321    }
322
323    #[test]
324    fn parse_failure_returns_structured_error_output() {
325        let output = lint_query_file(
326            &catalog("node Person { name: String }"),
327            "query broken(",
328            "/tmp/queries.gq",
329            QueryLintSchemaSource::file("/tmp/schema.pg"),
330        );
331
332        assert_eq!(output.status, QueryLintStatus::Error);
333        assert_eq!(output.queries_processed, 0);
334        assert_eq!(output.errors, 1);
335        assert!(output.results.is_empty());
336        assert_eq!(output.findings.len(), 1);
337        assert_eq!(output.findings[0].severity, QueryLintSeverity::Error);
338        assert_eq!(output.findings[0].code, PARSE_ERROR_CODE);
339    }
340
341    #[test]
342    fn mixed_valid_and_invalid_queries_preserve_per_query_results() {
343        let output = lint_query_file(
344            &catalog(
345                r#"
346node Person {
347    slug: String @key
348    name: String?
349}
350"#,
351            ),
352            r#"
353query list_people() {
354    match { $p: Person }
355    return { $p.name }
356}
357
358query bad_update($slug: String) {
359    update Person set { missing: "nope" } where slug = $slug
360}
361"#,
362            "/tmp/queries.gq",
363            QueryLintSchemaSource::file("/tmp/schema.pg"),
364        );
365
366        assert_eq!(output.queries_processed, 2);
367        assert_eq!(output.results[0].name, "list_people");
368        assert_eq!(output.results[0].status, QueryLintStatus::Ok);
369        assert_eq!(output.results[1].name, "bad_update");
370        assert_eq!(output.results[1].status, QueryLintStatus::Error);
371        assert!(
372            output.results[1]
373                .error
374                .as_deref()
375                .unwrap_or_default()
376                .contains("has no property")
377        );
378    }
379
380    #[test]
381    fn hardcoded_mutation_warning_only_fires_for_mutation_queries() {
382        let output = lint_query_file(
383            &catalog(
384                r#"
385node Person {
386    slug: String @key
387    name: String?
388}
389"#,
390            ),
391            r#"
392query list_people() {
393    match { $p: Person }
394    return { $p.name }
395}
396
397query insert_person() {
398    insert Person { slug: "p1", name: "P1" }
399}
400"#,
401            "/tmp/queries.gq",
402            QueryLintSchemaSource::file("/tmp/schema.pg"),
403        );
404
405        assert!(output.results[0].warnings.is_empty());
406        assert_eq!(
407            output.results[1].warnings,
408            vec![HARDCODED_MUTATION_WARNING.to_string()]
409        );
410        assert_eq!(output.warnings, 1);
411    }
412
413    #[test]
414    fn l201_warns_for_nullable_uncovered_update_fields() {
415        let output = lint_query_file(
416            &catalog(
417                r#"
418node Policy {
419    slug: String @key
420    name: String?
421    effectiveTo: DateTime?
422}
423"#,
424            ),
425            r#"
426query update_policy($slug: String, $name: String) {
427    update Policy set { name: $name } where slug = $slug
428}
429"#,
430            "/tmp/queries.gq",
431            QueryLintSchemaSource::file("/tmp/schema.pg"),
432        );
433
434        assert_eq!(output.findings.len(), 1);
435        assert_eq!(output.findings[0].code, L201_CODE);
436        assert_eq!(
437            output.findings[0].message,
438            "Policy.effectiveTo exists in schema but no update query sets it"
439        );
440        assert_eq!(output.findings[0].query_names, vec!["update_policy"]);
441    }
442
443    #[test]
444    fn l201_does_not_fire_without_valid_update_queries() {
445        let output = lint_query_file(
446            &catalog(
447                r#"
448node Policy {
449    slug: String @key
450    effectiveTo: DateTime?
451}
452"#,
453            ),
454            r#"
455query insert_policy($slug: String) {
456    insert Policy { slug: $slug }
457}
458"#,
459            "/tmp/queries.gq",
460            QueryLintSchemaSource::file("/tmp/schema.pg"),
461        );
462
463        assert!(output.findings.is_empty());
464    }
465
466    #[test]
467    fn l201_excludes_embed_target_properties() {
468        let output = lint_query_file(
469            &catalog(
470                r#"
471node Doc {
472    slug: String @key
473    body: String?
474    summary: String?
475    embedding: Vector(3)? @embed(body)
476}
477"#,
478            ),
479            r#"
480query update_doc($slug: String, $body: String) {
481    update Doc set { body: $body } where slug = $slug
482}
483"#,
484            "/tmp/queries.gq",
485            QueryLintSchemaSource::file("/tmp/schema.pg"),
486        );
487
488        assert_eq!(output.findings.len(), 1);
489        assert_eq!(output.findings[0].property.as_deref(), Some("summary"));
490    }
491
492    #[test]
493    fn l201_excludes_key_properties_even_if_catalog_is_modified() {
494        let mut catalog = catalog(
495            r#"
496node Policy {
497    slug: String @key
498    name: String?
499}
500"#,
501        );
502        catalog
503            .node_types
504            .get_mut("Policy")
505            .unwrap()
506            .properties
507            .get_mut("slug")
508            .unwrap()
509            .nullable = true;
510
511        let output = lint_query_file(
512            &catalog,
513            r#"
514query update_policy($slug: String, $name: String) {
515    update Policy set { name: $name } where slug = $slug
516}
517"#,
518            "/tmp/queries.gq",
519            QueryLintSchemaSource::file("/tmp/schema.pg"),
520        );
521
522        assert!(
523            output
524                .findings
525                .iter()
526                .all(|finding| finding.property.as_deref() != Some("slug"))
527        );
528    }
529
530    #[test]
531    fn findings_and_query_names_are_deterministic() {
532        let output = lint_query_file(
533            &catalog(
534                r#"
535node Policy {
536    slug: String @key
537    c_field: String?
538    b_field: String?
539    a_field: String?
540}
541"#,
542            ),
543            &r#"
544query update_b($slug: String) {
545    update Policy set { a_field: "x" } where slug = $slug
546}
547
548query update_a($slug: String) {
549    update Policy set { a_field: "x" } where slug = $slug
550}
551"#,
552            "/tmp/queries.gq",
553            QueryLintSchemaSource::file("/tmp/schema.pg"),
554        );
555
556        assert_eq!(output.findings.len(), 2);
557        assert_eq!(output.findings[0].property.as_deref(), Some("b_field"));
558        assert_eq!(output.findings[1].property.as_deref(), Some("c_field"));
559        assert_eq!(output.findings[0].query_names, vec!["update_a", "update_b"]);
560        assert_eq!(output.findings[1].query_names, vec!["update_a", "update_b"]);
561    }
562}