1use std::collections::{BTreeMap, BTreeSet};
16use std::fmt;
17
18use hawdb_core::{RelationshipDirection, Value};
19use hawdb_plan_cypher::{ComparisonOp, LogicalPlan, Predicate, Projection, ProjectionExpression};
20use hawdb_sql_syntax::{BinaryOperatorSyntax, GraphEdgeDirection, Span, UnaryOperatorSyntax};
21
22use super::{
23 BoundPgqExpression, BoundPgqExpressionKind, BoundPgqGraphTable, BoundPgqLiteral,
24 BoundPgqPathPrimary, BoundPgqVariable, PgqSlotId,
25};
26
27pub type PgqLoweringParameters = BTreeMap<u32, Value>;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PgqLoweringErrorCode {
31 InvalidBoundPlan,
32 UnsupportedPath,
33 UnsupportedLabels,
34 UnsupportedExpression,
35 MissingParameter,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct PgqLoweringError {
40 pub code: PgqLoweringErrorCode,
41 pub span: Span,
42 pub message: String,
43}
44
45impl PgqLoweringError {
46 fn new(code: PgqLoweringErrorCode, span: Span, message: impl Into<String>) -> Self {
47 Self {
48 code,
49 span,
50 message: message.into(),
51 }
52 }
53}
54
55impl fmt::Display for PgqLoweringError {
56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57 write!(
58 formatter,
59 "SQL/PGQ lowering error {:?} at bytes {}..{}: {}",
60 self.code, self.span.start, self.span.end, self.message
61 )
62 }
63}
64
65impl std::error::Error for PgqLoweringError {}
66
67pub fn lower_bound_pgq_graph_table(
73 table: &BoundPgqGraphTable,
74 parameters: &PgqLoweringParameters,
75) -> Result<LogicalPlan, PgqLoweringError> {
76 let path = match table.pattern.paths.as_slice() {
77 [path] => path,
78 _ => {
79 return Err(PgqLoweringError::new(
80 PgqLoweringErrorCode::InvalidBoundPlan,
81 table.span,
82 "the executable GRAPH_TABLE subset requires exactly one bound path",
83 ));
84 }
85 };
86 let slots = SlotBindings::new(&table.variables)?;
87 let mut factors = path.factors.iter();
88 let first = factors.next().ok_or_else(|| {
89 PgqLoweringError::new(
90 PgqLoweringErrorCode::InvalidBoundPlan,
91 table.span,
92 "the bound graph path is empty",
93 )
94 })?;
95 if first.quantifier.is_some() {
96 return Err(PgqLoweringError::new(
97 PgqLoweringErrorCode::UnsupportedPath,
98 slots.span(primary_slot(&first.primary)),
99 "vertex quantifiers are not executable in the shared graph plan",
100 ));
101 }
102 let BoundPgqPathPrimary::Vertex {
103 slot: first_slot,
104 predicate: first_predicate,
105 } = &first.primary
106 else {
107 return Err(PgqLoweringError::new(
108 PgqLoweringErrorCode::InvalidBoundPlan,
109 slots.span(primary_slot(&first.primary)),
110 "a bound graph path must start with a vertex",
111 ));
112 };
113
114 let first_variable = slots.variable(*first_slot)?;
115 let mut path_slots = BTreeSet::from([*first_slot]);
116 let mut current_name = slots.name(*first_slot)?.to_string();
117 let mut current_label = executable_label(first_variable)?;
118 let mut plan = LogicalPlan::NodeScan {
119 variable: current_name.clone(),
120 label: current_label.clone(),
121 };
122 if let Some(predicate) = first_predicate {
123 plan = filter_plan(plan, lower_predicate(predicate, &slots, parameters)?);
124 }
125
126 while let Some(edge_factor) = factors.next() {
127 let Some(target_factor) = factors.next() else {
128 return Err(PgqLoweringError::new(
129 PgqLoweringErrorCode::InvalidBoundPlan,
130 table.span,
131 "a bound edge is missing its target vertex",
132 ));
133 };
134 if edge_factor
135 .quantifier
136 .as_ref()
137 .is_some_and(|range| range.min != 1 || range.max != 1)
138 {
139 return Err(PgqLoweringError::new(
140 PgqLoweringErrorCode::UnsupportedPath,
141 slots.span(primary_slot(&edge_factor.primary)),
142 "quantified paths require SQL/PGQ walk-semantics qualification",
143 ));
144 }
145 if target_factor.quantifier.is_some() {
146 return Err(PgqLoweringError::new(
147 PgqLoweringErrorCode::UnsupportedPath,
148 slots.span(primary_slot(&target_factor.primary)),
149 "vertex quantifiers are not executable in the shared graph plan",
150 ));
151 }
152 let BoundPgqPathPrimary::Edge {
153 slot: edge_slot,
154 direction,
155 predicate: edge_predicate,
156 } = &edge_factor.primary
157 else {
158 return Err(PgqLoweringError::new(
159 PgqLoweringErrorCode::InvalidBoundPlan,
160 slots.span(primary_slot(&edge_factor.primary)),
161 "bound graph paths must alternate vertex and edge factors",
162 ));
163 };
164 let BoundPgqPathPrimary::Vertex {
165 slot: target_slot,
166 predicate: target_predicate,
167 } = &target_factor.primary
168 else {
169 return Err(PgqLoweringError::new(
170 PgqLoweringErrorCode::UnsupportedPath,
171 slots.span(primary_slot(&target_factor.primary)),
172 "parenthesized paths are not executable in the shared graph plan",
173 ));
174 };
175
176 let edge = slots.variable(*edge_slot)?;
177 let target = slots.variable(*target_slot)?;
178 for slot in [*edge_slot, *target_slot] {
179 if !path_slots.insert(slot) {
180 return Err(PgqLoweringError::new(
181 PgqLoweringErrorCode::UnsupportedPath,
182 slots.span(slot),
183 "reused graph variables require an explicit identity constraint in the shared graph plan",
184 ));
185 }
186 }
187 let target_name = slots.name(*target_slot)?.to_string();
188 let target_label = executable_label(target)?;
189 plan = LogicalPlan::Expand {
190 source_variable: current_name,
191 source_label: current_label,
192 rel_variable: Some(slots.name(*edge_slot)?.to_string()),
193 rel_type: executable_label(edge)?,
194 rel_properties: BTreeMap::new(),
195 direction: lower_direction(*direction),
196 target_variable: target_name.clone(),
197 target_label: target_label.clone(),
198 min_hops: 1,
199 max_hops: 1,
200 optional: false,
201 input: Box::new(plan),
202 };
203 let mut predicates = Vec::new();
204 if let Some(predicate) = edge_predicate {
205 predicates.push(lower_predicate(predicate, &slots, parameters)?);
206 }
207 if let Some(predicate) = target_predicate {
208 predicates.push(lower_predicate(predicate, &slots, parameters)?);
209 }
210 if !predicates.is_empty() {
211 plan = filter_plan(plan, and_predicates(predicates));
212 }
213 current_name = target_name;
214 current_label = target_label;
215 }
216
217 if let Some(predicate) = &table.pattern.predicate {
218 plan = filter_plan(plan, lower_predicate(predicate, &slots, parameters)?);
219 }
220 let items = table
221 .columns
222 .iter()
223 .map(|column| {
224 Ok(Projection {
225 expression: lower_projection(&column.expression, &slots, parameters)?,
226 name: column.name.clone(),
227 })
228 })
229 .collect::<Result<Vec<_>, PgqLoweringError>>()?;
230 Ok(LogicalPlan::Project {
231 items,
232 input: Box::new(plan),
233 })
234}
235
236struct SlotBindings<'a> {
237 variables: BTreeMap<PgqSlotId, &'a BoundPgqVariable>,
238 names: BTreeMap<PgqSlotId, String>,
239}
240
241impl<'a> SlotBindings<'a> {
242 fn new(variables: &'a [BoundPgqVariable]) -> Result<Self, PgqLoweringError> {
243 let mut by_slot = BTreeMap::new();
244 let mut used_names = variables
245 .iter()
246 .filter_map(|variable| variable.name.clone())
247 .collect::<BTreeSet<_>>();
248 let mut names = BTreeMap::new();
249 for variable in variables {
250 if by_slot.insert(variable.slot, variable).is_some() {
251 return Err(PgqLoweringError::new(
252 PgqLoweringErrorCode::InvalidBoundPlan,
253 variable.span,
254 format!("duplicate bound graph slot {}", variable.slot.0),
255 ));
256 }
257 let name = variable.name.clone().unwrap_or_else(|| {
258 unique_internal_name(
259 format!("__hawdb_pgq_slot_{}", variable.slot.0),
260 &mut used_names,
261 )
262 });
263 names.insert(variable.slot, name);
264 }
265 Ok(Self {
266 variables: by_slot,
267 names,
268 })
269 }
270
271 fn variable(&self, slot: PgqSlotId) -> Result<&'a BoundPgqVariable, PgqLoweringError> {
272 self.variables.get(&slot).copied().ok_or_else(|| {
273 PgqLoweringError::new(
274 PgqLoweringErrorCode::InvalidBoundPlan,
275 Span::new(0, 0),
276 format!("bound graph slot {} is missing", slot.0),
277 )
278 })
279 }
280
281 fn name(&self, slot: PgqSlotId) -> Result<&str, PgqLoweringError> {
282 self.names.get(&slot).map(String::as_str).ok_or_else(|| {
283 PgqLoweringError::new(
284 PgqLoweringErrorCode::InvalidBoundPlan,
285 self.span(slot),
286 format!("bound graph slot {} has no logical variable", slot.0),
287 )
288 })
289 }
290
291 fn span(&self, slot: PgqSlotId) -> Span {
292 self.variables
293 .get(&slot)
294 .map_or(Span::new(0, 0), |variable| variable.span)
295 }
296}
297
298fn unique_internal_name(base: String, used_names: &mut BTreeSet<String>) -> String {
299 if used_names.insert(base.clone()) {
300 return base;
301 }
302 let mut suffix = 1u32;
303 loop {
304 let candidate = format!("{base}_{suffix}");
305 if used_names.insert(candidate.clone()) {
306 return candidate;
307 }
308 suffix = suffix.saturating_add(1);
309 }
310}
311
312fn primary_slot(primary: &BoundPgqPathPrimary) -> PgqSlotId {
313 match primary {
314 BoundPgqPathPrimary::Vertex { slot, .. } | BoundPgqPathPrimary::Edge { slot, .. } => *slot,
315 BoundPgqPathPrimary::Parenthesized { path, .. } => path
316 .factors
317 .first()
318 .map_or(PgqSlotId(u32::MAX), |factor| primary_slot(&factor.primary)),
319 }
320}
321
322fn executable_label(variable: &BoundPgqVariable) -> Result<String, PgqLoweringError> {
323 match variable.labels.as_slice() {
324 [] => Ok(String::new()),
325 [label] => Ok(label.clone()),
326 labels => Err(PgqLoweringError::new(
327 PgqLoweringErrorCode::UnsupportedLabels,
328 variable.span,
329 format!(
330 "the shared graph scan supports one exact label per element, found {} labels",
331 labels.len()
332 ),
333 )),
334 }
335}
336
337fn lower_direction(direction: GraphEdgeDirection) -> RelationshipDirection {
338 match direction {
339 GraphEdgeDirection::Left => RelationshipDirection::Incoming,
340 GraphEdgeDirection::Right => RelationshipDirection::Outgoing,
341 GraphEdgeDirection::Any => RelationshipDirection::Undirected,
342 }
343}
344
345fn filter_plan(input: LogicalPlan, predicate: Predicate) -> LogicalPlan {
346 LogicalPlan::Filter {
347 predicate,
348 input: Box::new(input),
349 }
350}
351
352fn and_predicates(mut predicates: Vec<Predicate>) -> Predicate {
353 if predicates.len() == 1 {
354 predicates.pop().expect("one predicate remains")
355 } else {
356 Predicate::And(predicates)
357 }
358}
359
360fn lower_predicate(
361 expression: &BoundPgqExpression,
362 slots: &SlotBindings<'_>,
363 parameters: &PgqLoweringParameters,
364) -> Result<Predicate, PgqLoweringError> {
365 match &expression.kind {
366 BoundPgqExpressionKind::Literal(BoundPgqLiteral::Boolean(value)) => {
367 Ok(Predicate::ConstantBool(*value))
368 }
369 BoundPgqExpressionKind::Parameter(position) => {
370 match parameter(parameters, *position, expression.span)? {
371 Value::Bool(value) => Ok(Predicate::ConstantBool(value)),
372 value => Err(unsupported_expression(
373 expression.span,
374 format!("boolean predicate parameter ${position} resolved to {value:?}"),
375 )),
376 }
377 }
378 BoundPgqExpressionKind::Property { slot, property } => Ok(Predicate::PropertyEq {
379 variable: slots.name(*slot)?.to_string(),
380 property: property.clone(),
381 value: Value::Bool(true),
382 }),
383 BoundPgqExpressionKind::Unary {
384 operator: UnaryOperatorSyntax::Not,
385 expression,
386 } => Ok(Predicate::Not(Box::new(lower_predicate(
387 expression, slots, parameters,
388 )?))),
389 BoundPgqExpressionKind::Binary {
390 left,
391 operator: BinaryOperatorSyntax::And,
392 right,
393 } => Ok(Predicate::And(vec![
394 lower_predicate(left, slots, parameters)?,
395 lower_predicate(right, slots, parameters)?,
396 ])),
397 BoundPgqExpressionKind::Binary {
398 left,
399 operator: BinaryOperatorSyntax::Or,
400 right,
401 } => Ok(Predicate::Or(vec![
402 lower_predicate(left, slots, parameters)?,
403 lower_predicate(right, slots, parameters)?,
404 ])),
405 BoundPgqExpressionKind::Binary {
406 left,
407 operator,
408 right,
409 } if is_comparison(*operator) => {
410 lower_comparison(left, *operator, right, slots, parameters)
411 }
412 BoundPgqExpressionKind::IsNull {
413 expression: inner,
414 negated,
415 } => {
416 let BoundPgqExpressionKind::Property { slot, property } = &inner.kind else {
417 return Err(unsupported_expression(
418 expression.span,
419 "IS NULL currently requires a graph property",
420 ));
421 };
422 if *negated {
423 Ok(Predicate::PropertyIsNotNull {
424 variable: slots.name(*slot)?.to_string(),
425 property: property.clone(),
426 })
427 } else {
428 Ok(Predicate::PropertyIsNull {
429 variable: slots.name(*slot)?.to_string(),
430 property: property.clone(),
431 })
432 }
433 }
434 BoundPgqExpressionKind::InList {
435 expression: inner,
436 values,
437 negated,
438 } => {
439 let BoundPgqExpressionKind::Property { slot, property } = &inner.kind else {
440 return Err(unsupported_expression(
441 expression.span,
442 "IN currently requires a graph property on the left",
443 ));
444 };
445 let values = values
446 .iter()
447 .map(|value| lower_constant(value, parameters))
448 .collect::<Result<Vec<_>, _>>()?;
449 let predicate = Predicate::PropertyIn {
450 variable: slots.name(*slot)?.to_string(),
451 property: property.clone(),
452 values,
453 };
454 if *negated {
455 Ok(Predicate::Not(Box::new(predicate)))
456 } else {
457 Ok(predicate)
458 }
459 }
460 BoundPgqExpressionKind::Between {
461 expression: inner,
462 low,
463 high,
464 negated,
465 } => {
466 let predicate = Predicate::And(vec![
467 lower_comparison(
468 inner,
469 BinaryOperatorSyntax::GreaterOrEqual,
470 low,
471 slots,
472 parameters,
473 )?,
474 lower_comparison(
475 inner,
476 BinaryOperatorSyntax::LessOrEqual,
477 high,
478 slots,
479 parameters,
480 )?,
481 ]);
482 if *negated {
483 Ok(Predicate::Not(Box::new(predicate)))
484 } else {
485 Ok(predicate)
486 }
487 }
488 _ => Err(unsupported_expression(
489 expression.span,
490 "expression has no equivalent shared graph predicate",
491 )),
492 }
493}
494
495fn lower_comparison(
496 left: &BoundPgqExpression,
497 operator: BinaryOperatorSyntax,
498 right: &BoundPgqExpression,
499 slots: &SlotBindings<'_>,
500 parameters: &PgqLoweringParameters,
501) -> Result<Predicate, PgqLoweringError> {
502 let comparison_span = Span::new(left.span.start, right.span.end);
503 let left = lower_projection(left, slots, parameters)?;
504 let right = lower_projection(right, slots, parameters)?;
505 Ok(match operator {
506 BinaryOperatorSyntax::Equal => Predicate::ExpressionEq {
507 expression: left,
508 value: right,
509 },
510 BinaryOperatorSyntax::NotEqual => Predicate::ExpressionNotEq {
511 expression: left,
512 value: right,
513 },
514 BinaryOperatorSyntax::Less => Predicate::ExpressionCompare {
515 expression: left,
516 op: ComparisonOp::Lt,
517 value: right,
518 },
519 BinaryOperatorSyntax::LessOrEqual => Predicate::ExpressionCompare {
520 expression: left,
521 op: ComparisonOp::Lte,
522 value: right,
523 },
524 BinaryOperatorSyntax::Greater => Predicate::ExpressionCompare {
525 expression: left,
526 op: ComparisonOp::Gt,
527 value: right,
528 },
529 BinaryOperatorSyntax::GreaterOrEqual => Predicate::ExpressionCompare {
530 expression: left,
531 op: ComparisonOp::Gte,
532 value: right,
533 },
534 _ => {
535 return Err(unsupported_expression(
536 comparison_span,
537 "non-comparison operator reached comparison lowering",
538 ));
539 }
540 })
541}
542
543fn lower_projection(
544 expression: &BoundPgqExpression,
545 slots: &SlotBindings<'_>,
546 parameters: &PgqLoweringParameters,
547) -> Result<ProjectionExpression, PgqLoweringError> {
548 match &expression.kind {
549 BoundPgqExpressionKind::Variable(slot) => Ok(ProjectionExpression::Variable {
550 variable: slots.name(*slot)?.to_string(),
551 }),
552 BoundPgqExpressionKind::Property { slot, property } => Ok(ProjectionExpression::Property {
553 variable: slots.name(*slot)?.to_string(),
554 property: property.clone(),
555 }),
556 BoundPgqExpressionKind::Parameter(position) => Ok(ProjectionExpression::Literal(
557 parameter(parameters, *position, expression.span)?,
558 )),
559 BoundPgqExpressionKind::Literal(_) => Ok(ProjectionExpression::Literal(lower_constant(
560 expression, parameters,
561 )?)),
562 BoundPgqExpressionKind::Unary {
563 operator: UnaryOperatorSyntax::Plus,
564 expression,
565 } => lower_projection(expression, slots, parameters),
566 BoundPgqExpressionKind::Unary {
567 operator: UnaryOperatorSyntax::Minus,
568 expression: inner,
569 } => match lower_constant(inner, parameters)? {
570 Value::Int(value) => value
571 .checked_neg()
572 .map(Value::Int)
573 .map(ProjectionExpression::Literal)
574 .ok_or_else(|| {
575 unsupported_expression(expression.span, "integer negation overflows")
576 }),
577 Value::Float(value) => Ok(ProjectionExpression::Literal(Value::Float(-value))),
578 _ => Err(unsupported_expression(
579 expression.span,
580 "unary minus currently requires a numeric constant",
581 )),
582 },
583 BoundPgqExpressionKind::Function {
584 name,
585 arguments,
586 distinct: false,
587 } if name
588 .last()
589 .is_some_and(|name| name.eq_ignore_ascii_case("lower"))
590 && arguments.len() == 1 =>
591 {
592 Ok(ProjectionExpression::Lower(Box::new(lower_projection(
593 &arguments[0],
594 slots,
595 parameters,
596 )?)))
597 }
598 _ => Err(unsupported_expression(
599 expression.span,
600 "expression has no equivalent shared graph projection",
601 )),
602 }
603}
604
605fn lower_constant(
606 expression: &BoundPgqExpression,
607 parameters: &PgqLoweringParameters,
608) -> Result<Value, PgqLoweringError> {
609 match &expression.kind {
610 BoundPgqExpressionKind::Parameter(position) => {
611 parameter(parameters, *position, expression.span)
612 }
613 BoundPgqExpressionKind::Literal(literal) => Ok(match literal {
614 BoundPgqLiteral::Null => Value::Null,
615 BoundPgqLiteral::Boolean(value) => Value::Bool(*value),
616 BoundPgqLiteral::Int64(value) => Value::Int(*value),
617 BoundPgqLiteral::Float64(value) => Value::Float(*value),
618 BoundPgqLiteral::String(value) => Value::String(value.clone()),
619 BoundPgqLiteral::TypedString { value, .. } => Value::String(value.clone()),
620 }),
621 _ => Err(unsupported_expression(
622 expression.span,
623 "a constant literal or bound parameter is required",
624 )),
625 }
626}
627
628fn parameter(
629 parameters: &PgqLoweringParameters,
630 position: u32,
631 span: Span,
632) -> Result<Value, PgqLoweringError> {
633 parameters.get(&position).cloned().ok_or_else(|| {
634 PgqLoweringError::new(
635 PgqLoweringErrorCode::MissingParameter,
636 span,
637 format!("PostgreSQL parameter ${position} is not bound"),
638 )
639 })
640}
641
642fn is_comparison(operator: BinaryOperatorSyntax) -> bool {
643 matches!(
644 operator,
645 BinaryOperatorSyntax::Equal
646 | BinaryOperatorSyntax::NotEqual
647 | BinaryOperatorSyntax::Less
648 | BinaryOperatorSyntax::LessOrEqual
649 | BinaryOperatorSyntax::Greater
650 | BinaryOperatorSyntax::GreaterOrEqual
651 )
652}
653
654fn unsupported_expression(span: Span, message: impl Into<String>) -> PgqLoweringError {
655 PgqLoweringError::new(PgqLoweringErrorCode::UnsupportedExpression, span, message)
656}
657
658#[cfg(test)]
659mod tests;