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)]
313#[path = "lint_tests.rs"]
314mod tests;