omnigraph_compiler/query/
lint.rs1use 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;