Skip to main content

uqa_sql/semantics/
age_cypher.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! SQL argument rules and result-column conversions for Apache AGE Cypher calls.
8
9use std::collections::BTreeMap;
10
11use uqa_core::{agtype, Value};
12
13use crate::{SQLError, ScalarExpr};
14
15/// Validate the call shape before any graph state is inspected.
16pub fn analyze_call(
17    args: &[ScalarExpr],
18    evaluated: &[Value],
19    column_aliases: &[String],
20) -> Result<(String, String), SQLError> {
21    if !(2..=3).contains(&evaluated.len()) {
22        return Err(SQLError::TypeMismatch(
23            "cypher requires 2-3 args (graph_name, query_string[, parameters])".into(),
24        ));
25    }
26    if column_aliases.is_empty() {
27        return Err(SQLError::TypeMismatch(
28            "cypher requires a record definition: AS (column agtype, ...)".into(),
29        ));
30    }
31    if args.len() == 3 && !is_valid_parameter_expr(&args[2]) {
32        return Err(SQLError::TypeMismatch(
33            "cypher parameters must be supplied through an SQL parameter".into(),
34        ));
35    }
36
37    let graph = match &evaluated[0] {
38        Value::Str(s) => s.clone(),
39        _ => {
40            return Err(SQLError::TypeMismatch(
41                "cypher.graph_name must be string".into(),
42            ))
43        }
44    };
45    let query = match &evaluated[1] {
46        Value::Str(s) => s.clone(),
47        _ => {
48            return Err(SQLError::TypeMismatch(
49                "cypher.query_string must be string".into(),
50            ))
51        }
52    };
53    Ok((graph, query))
54}
55
56/// Coerce one cypher output value to the SQL type declared in the
57/// record definition, following AGE's cast behavior.
58pub fn coerce_to_column_type(
59    value: Value,
60    declared: &str,
61    column: &str,
62) -> Result<Value, SQLError> {
63    match declared {
64        // No type available (plain alias list) behaves like agtype.
65        "agtype" | "" => Ok(match value {
66            // Top-level SQL NULL stays NULL (renders empty in psql).
67            Value::Null => Value::Null,
68            other => Value::Str(agtype::render(&other)),
69        }),
70        "int2" | "smallint" => coerce_int(value, i64::from(i16::MIN), i64::from(i16::MAX)),
71        "int4" | "int" | "integer" => coerce_int(value, i64::from(i32::MIN), i64::from(i32::MAX)),
72        "int8" | "bigint" => coerce_int(value, i64::MIN, i64::MAX),
73        "float4" | "float8" | "float" | "real" | "double precision" => coerce_float(value),
74        "text" | "varchar" | "bpchar" | "char" | "name" => coerce_text(value),
75        "bool" | "boolean" => coerce_bool(value),
76        other => Err(SQLError::TypeMismatch(format!(
77            "cannot cast type agtype to {other} for column \"{column}\""
78        ))),
79    }
80}
81
82/// Re-parse a string operand as an agtype scalar, mirroring AGE's
83/// string-to-agtype cast path (`'42'::int` works, `'abc'::int` raises
84/// `invalid input syntax for type agtype`).
85fn parse_agtype_scalar(s: &str) -> Result<Value, SQLError> {
86    let trimmed = s.trim();
87    if let Ok(n) = trimmed.parse::<i64>() {
88        return Ok(Value::Int(n));
89    }
90    if let Ok(f) = trimmed.parse::<f64>() {
91        return Ok(Value::Float(f));
92    }
93    match trimmed {
94        "true" => Ok(Value::Bool(true)),
95        "false" => Ok(Value::Bool(false)),
96        "null" => Ok(Value::Null),
97        _ => Err(SQLError::TypeMismatch(format!(
98            "invalid input syntax for type agtype: expected agtype value, but found \"{s}\""
99        ))),
100    }
101}
102
103fn coerce_int(value: Value, min: i64, max: i64) -> Result<Value, SQLError> {
104    let out = match value {
105        Value::Null => return Ok(Value::Null),
106        Value::Int(n) => n,
107        // PostgreSQL float -> int casts round half to even.
108        Value::Float(f) => {
109            let rounded = f.round_ties_even();
110            let above_max = if max == i64::MAX {
111                rounded >= 9_223_372_036_854_775_808.0
112            } else {
113                rounded > max as f64
114            };
115            if !rounded.is_finite() || rounded < min as f64 || above_max {
116                return Err(SQLError::TypeMismatch("integer out of range".into()));
117            }
118            rounded as i64
119        }
120        Value::Bool(b) => i64::from(b),
121        Value::Str(s) => {
122            return coerce_int(parse_agtype_scalar(&s)?, min, max);
123        }
124        other => {
125            return Err(SQLError::TypeMismatch(format!(
126                "cannot cast agtype {} to type integer",
127                agtype::agtype_type_name(&other)
128            )));
129        }
130    };
131    if out < min || out > max {
132        return Err(SQLError::TypeMismatch("integer out of range".into()));
133    }
134    Ok(Value::Int(out))
135}
136
137fn coerce_float(value: Value) -> Result<Value, SQLError> {
138    match value {
139        Value::Null => Ok(Value::Null),
140        Value::Int(n) => Ok(Value::Float(n as f64)),
141        Value::Float(f) => Ok(Value::Float(f)),
142        Value::Str(s) => coerce_float(parse_agtype_scalar(&s)?),
143        other => Err(SQLError::TypeMismatch(format!(
144            "cannot cast agtype {} to type float",
145            agtype::agtype_type_name(&other)
146        ))),
147    }
148}
149
150fn coerce_text(value: Value) -> Result<Value, SQLError> {
151    match value {
152        Value::Null => Ok(Value::Null),
153        // Strings pass through raw (no JSON quoting) as text.
154        Value::Str(s) => Ok(Value::Str(s)),
155        other => {
156            // AGE refuses to cast graph entities to text.
157            if agtype::entity_kind(&other).is_some() {
158                return Err(SQLError::TypeMismatch(format!(
159                    "agtype_value_to_text: unsupported argument agtype {}",
160                    agtype::agtype_type_ordinal(&other)
161                )));
162            }
163            Ok(Value::Str(agtype::render(&other)))
164        }
165    }
166}
167
168fn coerce_bool(value: Value) -> Result<Value, SQLError> {
169    match value {
170        Value::Null => Ok(Value::Null),
171        Value::Bool(b) => Ok(Value::Bool(b)),
172        other => Err(SQLError::TypeMismatch(format!(
173            "cannot cast agtype {} to type boolean",
174            agtype::agtype_type_name(&other)
175        ))),
176    }
177}
178
179fn is_valid_parameter_expr(expr: &ScalarExpr) -> bool {
180    matches!(
181        expr,
182        ScalarExpr::Param(_)
183            | ScalarExpr::Literal(Value::Null)
184            | ScalarExpr::TypedLiteral {
185                parameter_index: Some(_),
186                ..
187            }
188    )
189}
190
191pub fn parameter_map(value: &Value) -> Result<BTreeMap<String, Value>, SQLError> {
192    match value {
193        Value::Null => Ok(BTreeMap::new()),
194        Value::Map(map) => Ok(map.clone()),
195        Value::Str(s) | Value::Json(s) | Value::JsonB(s) => {
196            let parsed = serde_json::from_str::<serde_json::Value>(s)
197                .map_err(|e| SQLError::TypeMismatch(format!("invalid cypher parameters: {e}")))?;
198            match crate::assignment::conversion::json_to_core_value(parsed) {
199                Value::Map(map) => Ok(map),
200                _ => Err(SQLError::TypeMismatch(
201                    "cypher parameters must be a map".into(),
202                )),
203            }
204        }
205        _ => Err(SQLError::TypeMismatch(
206            "cypher parameters must be a map".into(),
207        )),
208    }
209}