uqa_sql/semantics/
age_cypher.rs1use std::collections::BTreeMap;
10
11use uqa_core::{agtype, Value};
12
13use crate::{SQLError, ScalarExpr};
14
15pub 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
56pub fn coerce_to_column_type(
59 value: Value,
60 declared: &str,
61 column: &str,
62) -> Result<Value, SQLError> {
63 match declared {
64 "agtype" | "" => Ok(match value {
66 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
82fn 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 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 Value::Str(s) => Ok(Value::Str(s)),
155 other => {
156 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}