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}