Skip to content

Commit b96e1ef

Browse files
authored
Merge pull request #469 from mrkorchun/feat/query-operation-descriptors
feat(compiler): expose query operation descriptors
2 parents fdbf35c + b7c0fd9 commit b96e1ef

11 files changed

Lines changed: 732 additions & 24 deletions

File tree

crates/omnigraph-cli/tests/cli_data.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,11 @@ node Policy {
378378
query update_policy($slug: String, $name: String) {
379379
update Policy set { name: $name } where slug = $slug
380380
}
381+
382+
query list_policies() {
383+
match { $p: Policy }
384+
return { $p.name $p.effectiveTo }
385+
}
381386
"#,
382387
);
383388

@@ -395,15 +400,81 @@ query update_policy($slug: String, $name: String) {
395400

396401
assert_eq!(payload["status"], "ok");
397402
assert_eq!(payload["schema_source"]["kind"], "file");
398-
assert_eq!(payload["queries_processed"], 1);
403+
assert_eq!(payload["queries_processed"], 2);
399404
assert_eq!(payload["warnings"], 1);
405+
assert_eq!(
406+
payload["results"][0]["operation"],
407+
serde_json::json!({
408+
"result": [],
409+
"reads": [{ "kind": "node", "type_name": "Policy" }],
410+
"writes": [{ "kind": "node", "type_name": "Policy" }]
411+
})
412+
);
413+
assert_eq!(
414+
payload["results"][1]["operation"],
415+
serde_json::json!({
416+
"result": [
417+
{
418+
"name": "name",
419+
"kind": "string",
420+
"nullable": true
421+
},
422+
{
423+
"name": "effectiveTo",
424+
"kind": "datetime",
425+
"nullable": true
426+
}
427+
],
428+
"reads": [{ "kind": "node", "type_name": "Policy" }],
429+
"writes": []
430+
})
431+
);
400432
assert_eq!(payload["findings"][0]["code"], "L201");
401433
assert_eq!(
402434
payload["findings"][0]["message"],
403435
"Policy.effectiveTo exists in schema but no update query sets it"
404436
);
405437
}
406438

439+
#[test]
440+
fn query_lint_json_omits_operation_after_compile_failure() {
441+
let temp = tempdir().unwrap();
442+
let schema_path = temp.path().join("schema.pg");
443+
let query_path = temp.path().join("queries.gq");
444+
write_file(
445+
&schema_path,
446+
r#"
447+
node Person {
448+
slug: String @key
449+
}
450+
"#,
451+
);
452+
write_query_file(
453+
&query_path,
454+
r#"
455+
query broken($slug: String) {
456+
update Person set { missing: "nope" } where slug = $slug
457+
}
458+
"#,
459+
);
460+
461+
let output = output_failure(
462+
cli()
463+
.arg("query")
464+
.arg("lint")
465+
.arg("--query")
466+
.arg(&query_path)
467+
.arg("--schema")
468+
.arg(&schema_path)
469+
.arg("--json"),
470+
);
471+
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
472+
473+
assert_eq!(payload["status"], "error");
474+
assert_eq!(payload["results"][0]["status"], "error");
475+
assert!(payload["results"][0].get("operation").is_none());
476+
}
477+
407478
#[test]
408479
fn lint_top_level_matches_deprecated_query_lint_output() {
409480
let temp = tempdir().unwrap();

crates/omnigraph-compiler/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ pub use ir::ParamMap;
3030
pub use ir::lower::{lower_mutation_query, lower_query};
3131
pub use lint::{DiagnosticCode, Family, SafetyTier, Severity};
3232
pub use query::ast::Literal;
33+
pub use query::descriptor::{
34+
QueryGraphFact, QueryGraphFactKind, QueryOperationDescriptor, QueryResultFieldDescriptor,
35+
QueryValueKind, describe_query_operation,
36+
};
3337
pub use query::lint::{
3438
QueryLintFinding, QueryLintOutput, QueryLintQueryKind, QueryLintQueryResult,
3539
QueryLintSchemaSource, QueryLintSchemaSourceKind, QueryLintSeverity, QueryLintStatus,
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
use std::collections::BTreeSet;
2+
3+
use arrow_schema::DataType;
4+
use serde::Serialize;
5+
6+
use crate::catalog::Catalog;
7+
use crate::error::{CompilerError, Result};
8+
9+
use super::ast::{Clause, Mutation, QueryDecl};
10+
use super::typecheck::{
11+
CheckedQuery, MutationTarget, infer_query_result_schema, typecheck_query_decl,
12+
};
13+
14+
/// A compiled query's conservative graph access set and result shape.
15+
///
16+
/// `reads` is conservative: every table the supported execution path may
17+
/// inspect is present. `writes` is the exact set of tables the mutation may
18+
/// change. Both sets are sorted and deduplicated for deterministic consumers.
19+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20+
pub struct QueryOperationDescriptor {
21+
pub result: Vec<QueryResultFieldDescriptor>,
22+
pub reads: Vec<QueryGraphFact>,
23+
pub writes: Vec<QueryGraphFact>,
24+
}
25+
26+
/// One field in a compiled query result.
27+
///
28+
/// Kinds are decomposed so clients never have to parse Arrow display strings
29+
/// such as `Vector(3)` or `[Date]`.
30+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31+
pub struct QueryResultFieldDescriptor {
32+
pub name: String,
33+
pub kind: QueryValueKind,
34+
#[serde(skip_serializing_if = "Option::is_none")]
35+
pub item_kind: Option<QueryValueKind>,
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub vector_dim: Option<u32>,
38+
pub nullable: bool,
39+
}
40+
41+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42+
#[serde(rename_all = "snake_case")]
43+
pub enum QueryValueKind {
44+
String,
45+
Bool,
46+
Int,
47+
#[serde(rename = "bigint")]
48+
BigInt,
49+
Float,
50+
Date,
51+
#[serde(rename = "datetime")]
52+
DateTime,
53+
Blob,
54+
Vector,
55+
List,
56+
Object,
57+
}
58+
59+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
60+
pub struct QueryGraphFact {
61+
pub kind: QueryGraphFactKind,
62+
pub type_name: String,
63+
}
64+
65+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
66+
#[serde(rename_all = "lowercase")]
67+
pub enum QueryGraphFactKind {
68+
Node,
69+
Edge,
70+
}
71+
72+
/// Compile one parsed query into the shared operation descriptor.
73+
///
74+
/// This is the single compiler owner used by lint today and by future server
75+
/// and SDK projections. An invalid query returns an error rather than a
76+
/// partial descriptor.
77+
pub fn describe_query_operation(
78+
catalog: &Catalog,
79+
query: &QueryDecl,
80+
) -> Result<QueryOperationDescriptor> {
81+
let checked = typecheck_query_decl(catalog, query)?;
82+
describe_checked_query_operation(catalog, query, &checked)
83+
}
84+
85+
fn describe_checked_query_operation(
86+
catalog: &Catalog,
87+
query: &QueryDecl,
88+
checked: &CheckedQuery,
89+
) -> Result<QueryOperationDescriptor> {
90+
let mut reads = BTreeSet::new();
91+
let mut writes = BTreeSet::new();
92+
93+
let result = match checked {
94+
CheckedQuery::Read(ctx) => {
95+
collect_clause_reads(catalog, &query.match_clause, &mut reads)?;
96+
infer_query_result_schema(catalog, query, ctx)?
97+
.fields()
98+
.iter()
99+
.map(result_field_descriptor)
100+
.collect::<Result<Vec<_>>>()?
101+
}
102+
CheckedQuery::Mutation(ctx) => {
103+
if ctx.targets.len() != query.mutations.len() {
104+
return Err(CompilerError::Type(
105+
"typechecked mutation target count does not match the query".to_string(),
106+
));
107+
}
108+
for (mutation, target) in query.mutations.iter().zip(&ctx.targets) {
109+
let fact = target_fact(target);
110+
reads.insert(fact.clone());
111+
writes.insert(fact);
112+
113+
match (mutation, target) {
114+
(Mutation::Insert(_), MutationTarget::Edge { type_name }) => {
115+
let edge = catalog.edge_types.get(type_name).ok_or_else(|| {
116+
CompilerError::Type(format!(
117+
"typechecked edge type `{type_name}` is absent from the catalog"
118+
))
119+
})?;
120+
reads.insert(node_fact(&edge.from_type));
121+
reads.insert(node_fact(&edge.to_type));
122+
}
123+
(Mutation::Delete(_), MutationTarget::Node { type_name }) => {
124+
for edge in catalog.edge_types.values().filter(|edge| {
125+
edge.from_type == *type_name || edge.to_type == *type_name
126+
}) {
127+
let edge_fact = QueryGraphFact {
128+
kind: QueryGraphFactKind::Edge,
129+
type_name: edge.name.clone(),
130+
};
131+
reads.insert(edge_fact.clone());
132+
writes.insert(edge_fact);
133+
}
134+
}
135+
_ => {}
136+
}
137+
}
138+
Vec::new()
139+
}
140+
};
141+
142+
Ok(QueryOperationDescriptor {
143+
result,
144+
reads: reads.into_iter().collect(),
145+
writes: writes.into_iter().collect(),
146+
})
147+
}
148+
149+
fn collect_clause_reads(
150+
catalog: &Catalog,
151+
clauses: &[Clause],
152+
reads: &mut BTreeSet<QueryGraphFact>,
153+
) -> Result<()> {
154+
for clause in clauses {
155+
match clause {
156+
Clause::Binding(binding) => {
157+
reads.insert(node_fact(&binding.type_name));
158+
}
159+
Clause::Traversal(traversal) => {
160+
let edge = catalog
161+
.lookup_edge_by_name(&traversal.edge_name)
162+
.ok_or_else(|| {
163+
CompilerError::Type(format!(
164+
"typechecked edge type `{}` is absent from the catalog",
165+
traversal.edge_name
166+
))
167+
})?;
168+
reads.insert(QueryGraphFact {
169+
kind: QueryGraphFactKind::Edge,
170+
type_name: edge.name.clone(),
171+
});
172+
reads.insert(node_fact(&edge.from_type));
173+
reads.insert(node_fact(&edge.to_type));
174+
}
175+
Clause::Negation(inner) => collect_clause_reads(catalog, inner, reads)?,
176+
Clause::Filter(_) => {}
177+
}
178+
}
179+
Ok(())
180+
}
181+
182+
fn target_fact(target: &MutationTarget) -> QueryGraphFact {
183+
match target {
184+
MutationTarget::Node { type_name } => node_fact(type_name),
185+
MutationTarget::Edge { type_name } => QueryGraphFact {
186+
kind: QueryGraphFactKind::Edge,
187+
type_name: type_name.clone(),
188+
},
189+
}
190+
}
191+
192+
fn node_fact(type_name: &str) -> QueryGraphFact {
193+
QueryGraphFact {
194+
kind: QueryGraphFactKind::Node,
195+
type_name: type_name.to_string(),
196+
}
197+
}
198+
199+
fn result_field_descriptor(field: &arrow_schema::FieldRef) -> Result<QueryResultFieldDescriptor> {
200+
let (kind, item_kind, vector_dim) = result_value_shape(field.data_type())?;
201+
Ok(QueryResultFieldDescriptor {
202+
name: field.name().clone(),
203+
kind,
204+
item_kind,
205+
vector_dim,
206+
nullable: field.is_nullable(),
207+
})
208+
}
209+
210+
fn result_value_shape(
211+
data_type: &DataType,
212+
) -> Result<(QueryValueKind, Option<QueryValueKind>, Option<u32>)> {
213+
match data_type {
214+
DataType::FixedSizeList(field, dim) if field.data_type() == &DataType::Float32 => {
215+
let dim = u32::try_from(*dim).map_err(|_| {
216+
CompilerError::Type(format!(
217+
"query result vector dimension `{dim}` cannot be represented"
218+
))
219+
})?;
220+
Ok((QueryValueKind::Vector, None, Some(dim)))
221+
}
222+
DataType::List(field) => {
223+
let (item_kind, nested_item, vector_dim) = result_value_shape(field.data_type())?;
224+
if nested_item.is_some() || item_kind == QueryValueKind::List {
225+
return Err(CompilerError::Type(
226+
"nested query result lists are not representable".to_string(),
227+
));
228+
}
229+
Ok((QueryValueKind::List, Some(item_kind), vector_dim))
230+
}
231+
DataType::Struct(_) => Ok((QueryValueKind::Object, None, None)),
232+
scalar => Ok((scalar_result_kind(scalar)?, None, None)),
233+
}
234+
}
235+
236+
fn scalar_result_kind(data_type: &DataType) -> Result<QueryValueKind> {
237+
match data_type {
238+
DataType::Utf8 => Ok(QueryValueKind::String),
239+
DataType::Boolean => Ok(QueryValueKind::Bool),
240+
DataType::Int32 | DataType::UInt32 => Ok(QueryValueKind::Int),
241+
DataType::Int64 | DataType::UInt64 => Ok(QueryValueKind::BigInt),
242+
DataType::Float32 | DataType::Float64 => Ok(QueryValueKind::Float),
243+
DataType::Date32 => Ok(QueryValueKind::Date),
244+
DataType::Date64 => Ok(QueryValueKind::DateTime),
245+
DataType::LargeBinary => Ok(QueryValueKind::Blob),
246+
other => Err(CompilerError::Type(format!(
247+
"query result data type `{other}` is not representable"
248+
))),
249+
}
250+
}

0 commit comments

Comments
 (0)