From e7eb0328506747285d7b6d832762768c7d0f9002 Mon Sep 17 00:00:00 2001 From: David W Bitner Date: Tue, 28 Oct 2025 15:26:22 -0500 Subject: [PATCH] add options to toducksql --- agents.txt | 119 ++++++++++++ src/duckdb.rs | 36 +++- src/lib.rs | 2 +- src/sql.rs | 511 ++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 520 insertions(+), 148 deletions(-) create mode 100644 agents.txt diff --git a/agents.txt b/agents.txt new file mode 100644 index 00000000..39dc4cb3 --- /dev/null +++ b/agents.txt @@ -0,0 +1,119 @@ +# CQL2-RS Project AI Agent Configuration + +## Project Overview +This is cql2-rs, a Rust library with Python bindings for parsing, validating, and converting Common Query Language 2 (CQL2) expressions. The project supports text and JSON CQL2 formats and can convert to SQL for various database backends. + +## Key Technologies +- **Primary Language**: Rust (edition 2021) +- **Python Bindings**: PyO3 and maturin +- **Parser**: Pest parser generator +- **SQL Generation**: sqlparser-rs with serde features +- **Geometry**: geo-types, geojson, geozero, wkt +- **Testing**: rstest, pytest +- **Documentation**: mkdocs +- **Package Management**: Cargo (Rust), uv (Python) +- **Linting**: rustfmt, ruff, clippy + +## Project Structure +``` +├── src/ # Core Rust library (CQL2 parsing, validation, SQL conversion) +├── python/ # Python bindings using PyO3 +├── cli/ # Command-line interface +├── wasm/ # WebAssembly bindings +├── tests/ # Rust unit and integration tests +├── examples/ # Example CQL2 expressions and usage demos +├── docs/ # Documentation source +└── scripts/ # Development scripts (test, lint, build) +``` + +## Core Capabilities +- Parse CQL2 text and JSON expressions +- Validate CQL2 syntax and semantics +- Convert between CQL2 text, JSON, and SQL formats +- Support for spatial, temporal, and array operations +- Database-specific SQL generation (standard SQL, DuckDB) +- Direct AST access without string reparsing +- Python API with comprehensive type stubs + +## Development Guidelines + +### Code Quality +- Use `scripts/lint` for comprehensive linting (rustfmt, clippy, ruff) +- Use `scripts/test` for running all tests (Rust + Python) +- Maintain type safety and comprehensive error handling +- Follow Rust API guidelines and naming conventions +- Use descriptive error messages and documentation + +### Testing +- Write unit tests for all core functionality +- Include integration tests for CQL2 -> SQL conversion +- Test Python bindings separately with pytest +- Use rstest for parameterized Rust tests +- Validate against OGC CQL2 specification examples + +### Documentation +- Maintain rustdoc comments for all public APIs +- Keep Python type stubs (cql2.pyi) synchronized +- Update README examples when adding features +- Document breaking changes in CHANGELOG.md + +### Dependencies +- Prefer well-maintained crates with active communities +- Use workspace dependencies for version consistency +- Enable only necessary features to minimize build time +- Keep Python dependencies minimal and compatible + +## Common Tasks + +### Adding New CQL2 Features +1. Update grammar in `src/cql2.pest` +2. Modify parser in `src/parser.rs` +3. Add SQL conversion logic in `src/sql.rs` or `src/duckdb.rs` +4. Update Python bindings in `python/src/lib.rs` +5. Add comprehensive tests and examples +6. Update type stubs and documentation + +### SQL Backend Support +- Standard SQL: `src/sql.rs` +- DuckDB-specific: `src/duckdb.rs` +- Consider spatial function differences between backends +- Test array operations thoroughly + +### Python Binding Changes +- Use PyO3 best practices for error handling +- Maintain compatibility with sqloxide and other AST tools +- Provide both string and direct AST access methods +- Update type stubs in `cql2.pyi` + +## Performance Considerations +- Parser uses zero-copy where possible +- AST manipulation avoids string reparsing +- SQL generation is lazy and cached +- Python bindings minimize copies between Rust/Python + +## Error Handling +- Use thiserror for Rust error definitions +- Provide context-rich error messages +- Map Rust errors to appropriate Python exceptions +- Include position information for parse errors + +## Release Process +- Follow semantic versioning +- Update CHANGELOG.md with all changes +- Coordinate Rust crate and Python package releases +- Test across supported Python versions +- Update documentation and examples + +## External Integration +- Compatible with sqloxide for AST manipulation +- Supports geospatial libraries (geo, geojson) +- Works with popular SQL databases +- Can be embedded in larger geospatial applications + +## Troubleshooting +- Use `RUST_LOG=debug` for detailed logging +- Check `target/debug/` for intermediate build artifacts +- Python binding issues often relate to maturin configuration +- Spatial operations may need specific geometry formats + +This project bridges the gap between CQL2 specifications and practical SQL usage, making it easier to work with geospatial query languages in both Rust and Python ecosystems. diff --git a/src/duckdb.rs b/src/duckdb.rs index 4496c913..07381075 100644 --- a/src/duckdb.rs +++ b/src/duckdb.rs @@ -1,7 +1,7 @@ use crate::sql::func; use crate::Error; use crate::Expr; -use crate::ToSqlAst; +use crate::{ToSqlAst, ToSqlOptions}; use sqlparser::ast::visit_expressions_mut; use sqlparser::ast::Expr as SqlExpr; use std::ops::ControlFlow; @@ -9,7 +9,12 @@ use std::ops::ControlFlow; /// Traits for generating SQL for DuckDB with Spatial Extension pub trait ToDuckSQL { /// Convert Expression to SQL for DuckDB with Spatial Extension - fn to_ducksql(&self) -> Result; + fn to_ducksql(&self) -> Result { + self.to_ducksql_with_options(ToSqlOptions::default()) + } + + /// Convert Expression to DuckDB SQL using custom SQL generation options. + fn to_ducksql_with_options(&self, options: ToSqlOptions<'_>) -> Result; } impl ToDuckSQL for Expr { @@ -46,8 +51,8 @@ impl ToDuckSQL for Expr { /// let expr: Expr = "t_overlaps(interval(a,b),interval('2020-01-01T00:00:00Z','2020-02-01T00:00:00Z'))".parse().unwrap(); /// assert_eq!(expr.to_ducksql().unwrap(), "(a < CAST('2020-02-01T00:00:00Z' AS TIMESTAMP WITH TIME ZONE) AND CAST('2020-01-01T00:00:00Z' AS TIMESTAMP WITH TIME ZONE) < b AND b < CAST('2020-02-01T00:00:00Z' AS TIMESTAMP WITH TIME ZONE))"); /// ``` - fn to_ducksql(&self) -> Result { - let mut ast = self.to_sql_ast()?; + fn to_ducksql_with_options(&self, options: ToSqlOptions<'_>) -> Result { + let mut ast = self.to_sql_ast_with_options(options)?; let _ = visit_expressions_mut(&mut ast, |expr| { if let SqlExpr::BinaryOp { op, right, left } = expr { match *op { @@ -73,7 +78,7 @@ impl ToDuckSQL for Expr { #[cfg(test)] mod tests { use super::ToDuckSQL; - use crate::Expr; + use crate::{Expr, NameKind, ToSqlOptions}; #[test] fn test_to_ducksql() { @@ -86,4 +91,25 @@ mod tests { let expr: Expr = "a_contains(foo, bar)".parse().unwrap(); assert_eq!(expr.to_ducksql().unwrap(), "list_has_all(foo, bar)"); } + + #[test] + fn test_ducksql_with_options() { + let expr: Expr = "collection = 'landsat' AND a_contains(tags, required)" + .parse() + .unwrap(); + let resolver = |name: &str, kind: NameKind| match (kind, name) { + (NameKind::Property, "collection") => Some("payload ->> 'collection'".to_string()), + (NameKind::Property, "tags") => Some("payload -> 'tags'".to_string()), + _ => None, + }; + + let sql = expr + .to_ducksql_with_options(ToSqlOptions::with_callback(&resolver)) + .unwrap(); + + assert_eq!( + sql, + "payload ->> 'collection' = 'landsat' AND list_has_all(payload -> 'tags', required)" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index ce0766c6..0ed4ff70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ pub use error::Error; pub use expr::*; pub use geometry::{spatial_op, Geometry}; pub use parser::parse_text; -pub use sql::ToSqlAst; +pub use sql::{NameKind, ToSqlAst, ToSqlOptions}; use std::{fs, path::Path}; pub use temporal::{temporal_op, DateRange}; pub use validator::Validator; diff --git a/src/sql.rs b/src/sql.rs index 8a3e9cc7..65ebe067 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -2,21 +2,135 @@ use crate::Error; use crate::Expr; use crate::Geometry; use pg_escape::quote_identifier; +use serde_json::{Map as JsonMap, Value as JsonValue}; use sqlparser::ast::DataType::{Date, Timestamp}; use sqlparser::ast::Expr::Value as ValExpr; use sqlparser::ast::Expr::{Cast, Nested}; use sqlparser::ast::{ Array as SqlArray, BinaryOperator, CastKind, Expr as SqlExpr, FunctionArgumentList, - FunctionArguments, Ident, TimezoneInfo, Value, + FunctionArguments, Ident, ObjectName, ObjectNamePart, SelectItem, SetExpr, Statement, + TimezoneInfo, Value, }; +use sqlparser::dialect::PostgreSqlDialect; +use sqlparser::parser::Parser; +use std::fmt; use std::vec; +/// Identifies whether a name references a function or a property during SQL generation. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum NameKind { + /// Function identifiers such as `st_intersects`. + Function, + /// Property identifiers such as `collection`. + Property, +} + +#[derive(Copy, Clone)] +enum NameResolver<'a> { + Callback(&'a dyn Fn(&str, NameKind) -> Option), + Json(&'a JsonMap), +} + +/// Options that control how SQL is generated from expressions. +/// +/// # Examples +/// +/// Mapping properties with a custom callback: +/// +/// ``` +/// use cql2::{Expr, NameKind, ToSqlAst, ToSqlOptions}; +/// +/// let expr: Expr = "collection = 'landsat'".parse().unwrap(); +/// let resolver = |name: &str, kind: NameKind| match (kind, name) { +/// (NameKind::Property, "collection") => Some("payload ->> 'collection'".to_string()), +/// _ => None, +/// }; +/// +/// let sql = expr +/// .to_sql_with_options(ToSqlOptions::with_callback(&resolver)) +/// .unwrap(); +/// +/// assert_eq!(sql, "payload ->> 'collection' = 'landsat'"); +/// ``` +/// +/// Using a JSON whitelist for functions and properties: +/// +/// ``` +/// use cql2::{Expr, ToSqlAst, ToSqlOptions}; +/// use serde_json::json; +/// +/// let expr: Expr = "casei(name)".parse().unwrap(); +/// let mapping = json!({ +/// "functions": {"lower": "custom.lower"}, +/// "properties": {"collection": "payload ->> 'collection'"} +/// }); +/// let map = mapping.as_object().unwrap(); +/// +/// let sql = expr +/// .to_sql_with_options(ToSqlOptions::with_json(map)) +/// .unwrap(); +/// +/// assert_eq!(sql, "custom.lower(name)"); +/// ``` +#[derive(Copy, Clone, Default)] +pub struct ToSqlOptions<'a> { + resolver: Option>, +} + +impl<'a> ToSqlOptions<'a> { + /// Create a new options value with defaults. + pub fn new() -> Self { + Self::default() + } + + /// Configure a callback resolver that maps function/property names to SQL snippets. + pub fn with_callback(callback: &'a dyn Fn(&str, NameKind) -> Option) -> Self { + Self { + resolver: Some(NameResolver::Callback(callback)), + } + } + + /// Configure a JSON resolver containing `functions` and/or `properties` maps. + pub fn with_json(map: &'a JsonMap) -> Self { + Self { + resolver: Some(NameResolver::Json(map)), + } + } +} + +impl fmt::Debug for ToSqlOptions<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let resolver = match self.resolver { + Some(NameResolver::Callback(_)) => "Callback", + Some(NameResolver::Json(_)) => "Json", + None => "None", + }; + f.debug_struct("ToSqlOptions") + .field("resolver", &resolver) + .finish() + } +} + /// Trait for converting expressions to SQLParser AST nodes. pub trait ToSqlAst { /// Converts this expression to SQLParser AST. - fn to_sql_ast(&self) -> Result; + fn to_sql_ast(&self) -> Result { + self.to_sql_ast_with_options(ToSqlOptions::default()) + } + + /// Converts this expression to SQLParser AST with custom options. + fn to_sql_ast_with_options(&self, options: ToSqlOptions<'_>) -> Result; + /// Converts the expression to a SQL string. - fn to_sql(&self) -> Result; + fn to_sql(&self) -> Result { + self.to_sql_with_options(ToSqlOptions::default()) + } + + /// Converts the expression to a SQL string with custom options. + fn to_sql_with_options(&self, options: ToSqlOptions<'_>) -> Result { + let ast = self.to_sql_ast_with_options(options)?; + Ok(ast.to_string()) + } } fn cast(arg: SqlExpr, data_type: sqlparser::ast::DataType) -> SqlExpr { @@ -29,10 +143,18 @@ fn cast(arg: SqlExpr, data_type: sqlparser::ast::DataType) -> SqlExpr { } pub(crate) fn func(name: &str, args: Vec) -> SqlExpr { - SqlExpr::Function(sqlparser::ast::Function { - name: sqlparser::ast::ObjectName(vec![sqlparser::ast::ObjectNamePart::Identifier( - ident_inner(name), - )]), + func_with_options(name, args, ToSqlOptions::default()) + .unwrap_or_else(|_| panic!("invalid function mapping for {name}")) +} + +fn func_with_options( + name: &str, + args: Vec, + options: ToSqlOptions<'_>, +) -> Result { + let object_name = function_name(name, options)?; + Ok(SqlExpr::Function(sqlparser::ast::Function { + name: object_name, args: FunctionArguments::List(FunctionArgumentList { duplicate_treatment: None, args: args @@ -49,7 +171,7 @@ pub(crate) fn func(name: &str, args: Vec) -> SqlExpr { within_group: vec![], uses_odbc_syntax: false, parameters: FunctionArguments::None, - }) + })) } fn lit_expr(value: &str) -> SqlExpr { @@ -58,9 +180,9 @@ fn lit_expr(value: &str) -> SqlExpr { fn float_expr(value: &f64) -> SqlExpr { ValExpr(Value::Number(value.to_string(), false).into()) } -fn args2ast(args: &[Box]) -> Result, Error> { +fn args2ast(args: &[Box], options: ToSqlOptions<'_>) -> Result, Error> { args.iter() - .map(|arg| arg.to_sql_ast()) + .map(|arg| arg.to_sql_ast_with_options(options)) .collect::, _>>() } fn binop(op: BinaryOperator, args: Vec) -> SqlExpr { @@ -78,52 +200,55 @@ struct Targs { right_end: SqlExpr, } -fn lit_or_prop_to_ts(arg: &Expr) -> Result { - Ok(match arg { - Expr::Property { property } => ident(property), - Expr::Literal(v) => cast(lit_expr(v), Timestamp(None, TimezoneInfo::WithTimeZone)), - _ => return Err(Error::OperationError()), - }) +fn lit_or_prop_to_ts(arg: &Expr, options: ToSqlOptions<'_>) -> Result { + match arg { + Expr::Property { property } => property_expr(property, options), + Expr::Literal(v) => Ok(cast( + lit_expr(v), + Timestamp(None, TimezoneInfo::WithTimeZone), + )), + _ => Err(Error::OperationError()), + } } -fn lit_or_prop_to_date(arg: &Expr) -> Result { - Ok(match arg { - Expr::Property { property } => ident(property), - Expr::Literal(v) => cast(lit_expr(v), Date), - _ => return Err(Error::OperationError()), - }) +fn lit_or_prop_to_date(arg: &Expr, options: ToSqlOptions<'_>) -> Result { + match arg { + Expr::Property { property } => property_expr(property, options), + Expr::Literal(v) => Ok(cast(lit_expr(v), Date)), + _ => Err(Error::OperationError()), + } } -fn t_arg_to_interval(arg: &Expr) -> Result<(SqlExpr, SqlExpr), Error> { +fn t_arg_to_interval(arg: &Expr, options: ToSqlOptions<'_>) -> Result<(SqlExpr, SqlExpr), Error> { match arg { Expr::Interval { interval } => { - let start = lit_or_prop_to_ts(&interval[0])?; - let end = lit_or_prop_to_ts(&interval[1])?; + let start = lit_or_prop_to_ts(&interval[0], options)?; + let end = lit_or_prop_to_ts(&interval[1], options)?; Ok((start, end)) } Expr::Property { property } => { - let start = ident(property); - Ok((start.clone(), start.clone())) + let start = property_expr(property, options)?; + Ok((start.clone(), start)) } Expr::Date { date } => { let e = Expr::Date { date: date.clone() }; - let start = e.to_sql_ast()?; - Ok((start.clone(), start.clone())) + let start = e.to_sql_ast_with_options(options)?; + Ok((start.clone(), start)) } Expr::Timestamp { timestamp } => { let e = Expr::Timestamp { timestamp: timestamp.clone(), }; - let start = e.to_sql_ast()?; - Ok((start.clone(), start.clone())) + let start = e.to_sql_ast_with_options(options)?; + Ok((start.clone(), start)) } _ => Err(Error::OperationError()), } } -fn t_args(args: &[Box]) -> Result { - let (left_start, left_end) = t_arg_to_interval(args[0].as_ref())?; - let (right_start, right_end) = t_arg_to_interval(args[1].as_ref())?; +fn t_args(args: &[Box], options: ToSqlOptions<'_>) -> Result { + let (left_start, left_end) = t_arg_to_interval(args[0].as_ref(), options)?; + let (right_start, right_end) = t_arg_to_interval(args[1].as_ref(), options)?; Ok(Targs { left_start, left_end, @@ -216,216 +341,279 @@ fn ident(property: &str) -> SqlExpr { SqlExpr::Identifier(ident_inner(property)) } +fn property_expr(property: &str, options: ToSqlOptions<'_>) -> Result { + if let Some(mapped) = resolve_name(property, NameKind::Property, options)? { + parse_sql_expression(&mapped) + } else { + Ok(ident(property)) + } +} + +fn resolve_name( + original: &str, + kind: NameKind, + options: ToSqlOptions<'_>, +) -> Result, Error> { + let Some(resolver) = options.resolver else { + return Ok(None); + }; + + match resolver { + NameResolver::Callback(callback) => Ok(callback(original, kind)), + NameResolver::Json(map) => { + let key = match kind { + NameKind::Function => "functions", + NameKind::Property => "properties", + }; + + match map.get(key) { + Some(JsonValue::Object(section)) => match section.get(original) { + Some(JsonValue::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(Error::OperationError()), + None => Ok(None), + }, + Some(_) => Err(Error::OperationError()), + None => Ok(None), + } + } + } +} + +fn parse_sql_expression(expr: &str) -> Result { + let dialect = PostgreSqlDialect {}; + let sql = format!("SELECT {expr}"); + let statements = Parser::parse_sql(&dialect, &sql).map_err(|_| Error::OperationError())?; + if let Some(Statement::Query(query)) = statements.into_iter().next() { + if let SetExpr::Select(select) = *query.body { + if let Some(SelectItem::UnnamedExpr(expr)) = select.projection.into_iter().next() { + return Ok(expr); + } + } + } + Err(Error::OperationError()) +} + +fn function_name(name: &str, options: ToSqlOptions<'_>) -> Result { + let resolved = + resolve_name(name, NameKind::Function, options)?.unwrap_or_else(|| name.to_string()); + let parsed = parse_sql_expression(&resolved)?; + match parsed { + SqlExpr::Identifier(ident) => Ok(ObjectName(vec![ObjectNamePart::Identifier(ident)])), + SqlExpr::CompoundIdentifier(idents) => Ok(ObjectName( + idents.into_iter().map(ObjectNamePart::Identifier).collect(), + )), + _ => Err(Error::OperationError()), + } +} + impl ToSqlAst for Expr { - /// Converts this expression to SQLParser AST. - fn to_sql_ast(&self) -> Result { - Ok(match self { - Expr::Bool(v) => ValExpr(Value::Boolean(*v).into()), - Expr::Float(v) => float_expr(v), - Expr::Literal(v) => lit_expr(v), - Expr::Date { ref date } => lit_or_prop_to_date(date.as_ref())?, - Expr::Timestamp { ref timestamp } => lit_or_prop_to_ts(timestamp.as_ref())?, + fn to_sql_ast_with_options(&self, options: ToSqlOptions<'_>) -> Result { + match self { + Expr::Bool(v) => Ok(ValExpr(Value::Boolean(*v).into())), + Expr::Float(v) => Ok(float_expr(v)), + Expr::Literal(v) => Ok(lit_expr(v)), + Expr::Date { ref date } => lit_or_prop_to_date(date.as_ref(), options), + Expr::Timestamp { ref timestamp } => lit_or_prop_to_ts(timestamp.as_ref(), options), Expr::Interval { ref interval } => { - let start = lit_or_prop_to_ts(interval[0].as_ref())?; - let end = lit_or_prop_to_ts(interval[1].as_ref())?; - SqlExpr::Array(SqlArray { + let start = lit_or_prop_to_ts(interval[0].as_ref(), options)?; + let end = lit_or_prop_to_ts(interval[1].as_ref(), options)?; + Ok(SqlExpr::Array(SqlArray { elem: vec![start, end], named: true, - }) + })) } - Expr::Null => ValExpr(Value::Null.into()), + Expr::Null => Ok(ValExpr(Value::Null.into())), Expr::Geometry(v) => match v { Geometry::GeoJSON(v) => { let s = lit_expr(&v.to_string()); - func("st_geomfromgeojson", vec![s]) + func_with_options("st_geomfromgeojson", vec![s], options) } Geometry::Wkt(v) => { let s = lit_expr(&v.to_string()); - func("st_geomfromtext", vec![s]) + func_with_options("st_geomfromtext", vec![s], options) } }, - - Expr::BBox { bbox } => func("st_makeenvelope", args2ast(bbox)?), - Expr::Array(ref v) => SqlExpr::Array(SqlArray { - elem: args2ast(v)?, + Expr::BBox { bbox } => { + let args = args2ast(bbox, options)?; + func_with_options("st_makeenvelope", args, options) + } + Expr::Array(ref v) => Ok(SqlExpr::Array(SqlArray { + elem: args2ast(v, options)?, named: true, - }), - Expr::Property { property } => ident(property), + })), + Expr::Property { property } => property_expr(property, options), Expr::Operation { op, args } => { let op_str = op.to_lowercase(); - let a = args2ast(args)?; + let a = args2ast(args, options)?; match op_str.as_str() { - "isnull" => SqlExpr::IsNull(Box::new(a[0].clone())), - "not" => SqlExpr::UnaryOp { + "isnull" => Ok(SqlExpr::IsNull(Box::new(a[0].clone()))), + "not" => Ok(SqlExpr::UnaryOp { op: sqlparser::ast::UnaryOperator::Not, expr: Box::new(a[0].clone()), - }, - "between" => SqlExpr::Between { + }), + "between" => Ok(SqlExpr::Between { expr: Box::new(a[0].clone()), negated: false, low: Box::new(a[1].clone()), high: Box::new(a[2].clone()), - }, + }), "in" => { let expr = a[0].clone(); let items = a[1].clone(); - SqlExpr::AnyOp { + Ok(SqlExpr::AnyOp { left: Box::new(expr), compare_op: BinaryOperator::Eq, right: Box::new(items), is_some: true, - } + }) } "like" => { let expr = a[0].clone(); let pattern = a[1].clone(); - SqlExpr::Like { + Ok(SqlExpr::Like { expr: Box::new(expr), pattern: Box::new(pattern), escape_char: None, negated: false, any: false, - } + }) } - "accenti" => func("strip_accents", a), - "casei" => func("lower", a), - "and" => andop(a), - "or" => orop(a), - "=" | "a_equals" | "eq" => binop(BinaryOperator::Eq, a), - "<>" | "!=" | "ne" => binop(BinaryOperator::NotEq, a), - ">" | "gt" => binop(BinaryOperator::Gt, a), - ">=" | "ge" | "gte" => binop(BinaryOperator::GtEq, a), - "<" | "lt" => binop(BinaryOperator::Lt, a), - "<=" | "le" | "lte" => binop(BinaryOperator::LtEq, a), - "+" => binop(BinaryOperator::Plus, a), - "-" => binop(BinaryOperator::Minus, a), - "*" => binop(BinaryOperator::Multiply, a), - "/" => binop(BinaryOperator::Divide, a), - "%" => binop(BinaryOperator::Modulo, a), - "^" => func("power", a), - "s_intersects" | "st_intersects" | "intersects" => func("st_intersects", a), - "s_equals" | "st_equals" => func("st_equals", a), - "s_within" | "st_within" => func("st_within", a), - "s_contains" | "st_contains" => func("st_contains", a), - "s_crosses" | "st_crosses" => func("st_crosses", a), - "s_overlaps" | "st_overlaps" => func("st_overlaps", a), - "s_touches" | "st_touches" => func("st_touches", a), - "s_disjoint" | "st_disjoint" => func("st_disjoint", a), - "a_contains" => binop(BinaryOperator::AtArrow, a), - "a_containedby" => binop(BinaryOperator::ArrowAt, a), - "a_overlaps" => binop(BinaryOperator::AtAt, a), + "accenti" => func_with_options("strip_accents", a, options), + "casei" => func_with_options("lower", a, options), + "and" => Ok(andop(a)), + "or" => Ok(orop(a)), + "=" | "a_equals" | "eq" => Ok(binop(BinaryOperator::Eq, a)), + "<>" | "!=" | "ne" => Ok(binop(BinaryOperator::NotEq, a)), + ">" | "gt" => Ok(binop(BinaryOperator::Gt, a)), + ">=" | "ge" | "gte" => Ok(binop(BinaryOperator::GtEq, a)), + "<" | "lt" => Ok(binop(BinaryOperator::Lt, a)), + "<=" | "le" | "lte" => Ok(binop(BinaryOperator::LtEq, a)), + "+" => Ok(binop(BinaryOperator::Plus, a)), + "-" => Ok(binop(BinaryOperator::Minus, a)), + "*" => Ok(binop(BinaryOperator::Multiply, a)), + "/" => Ok(binop(BinaryOperator::Divide, a)), + "%" => Ok(binop(BinaryOperator::Modulo, a)), + "^" => func_with_options("power", a, options), + "s_intersects" | "st_intersects" | "intersects" => { + func_with_options("st_intersects", a, options) + } + "s_equals" | "st_equals" => func_with_options("st_equals", a, options), + "s_within" | "st_within" => func_with_options("st_within", a, options), + "s_contains" | "st_contains" => func_with_options("st_contains", a, options), + "s_crosses" | "st_crosses" => func_with_options("st_crosses", a, options), + "s_overlaps" | "st_overlaps" => func_with_options("st_overlaps", a, options), + "s_touches" | "st_touches" => func_with_options("st_touches", a, options), + "s_disjoint" | "st_disjoint" => func_with_options("st_disjoint", a, options), + "a_contains" => Ok(binop(BinaryOperator::AtArrow, a)), + "a_containedby" => Ok(binop(BinaryOperator::ArrowAt, a)), + "a_overlaps" => Ok(binop(BinaryOperator::AtAt, a)), "t_before" => { - let t = t_args(args)?; - ltop(t.left_end, t.right_start) + let t = t_args(args, options)?; + Ok(ltop(t.left_end, t.right_start)) } "t_after" => { - let t = t_args(args)?; - ltop(t.right_end, t.left_start) + let t = t_args(args, options)?; + Ok(ltop(t.right_end, t.left_start)) } "t_meets" => { - let t = t_args(args)?; - eqop(t.left_end, t.right_start) + let t = t_args(args, options)?; + Ok(eqop(t.left_end, t.right_start)) } "t_metby" => { - let t = t_args(args)?; - eqop(t.right_end, t.left_start) + let t = t_args(args, options)?; + Ok(eqop(t.right_end, t.left_start)) } "t_overlaps" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ ltop(t.left_start, t.right_end.clone()), ltop(t.right_start, t.left_end.clone()), ltop(t.left_end, t.right_end), - ])) + ]))) } "t_overlappedby" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ ltop(t.right_start, t.left_end.clone()), ltop(t.left_start, t.right_end.clone()), ltop(t.right_end, t.left_end), - ])) + ]))) } "t_starts" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ eqop(t.left_start, t.right_start.clone()), ltop(t.left_end, t.right_end), - ])) + ]))) } "t_startedby" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ eqop(t.right_start, t.left_start.clone()), ltop(t.right_end, t.left_end), - ])) + ]))) } "t_during" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ gtop(t.left_start, t.right_start), ltop(t.left_end, t.right_end), - ])) + ]))) } "t_contains" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ gtop(t.right_start, t.left_start), ltop(t.right_end, t.left_end), - ])) + ]))) } "t_finishes" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ eqop(t.left_end, t.right_end), gtop(t.left_start, t.right_start), - ])) + ]))) } "t_finishedby" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ eqop(t.right_end, t.left_end), gtop(t.right_start, t.left_start), - ])) + ]))) } "t_equals" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ eqop(t.left_start, t.right_start), eqop(t.left_end, t.right_end), - ])) + ]))) } "t_disjoint" => { - let t = t_args(args)?; - notop(wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(notop(wrap(andop(vec![ lteop(t.left_start, t.right_end), gteop(t.left_end, t.right_start), - ]))) + ])))) } "t_intersects" | "anyinteracts" => { - let t = t_args(args)?; - wrap(andop(vec![ + let t = t_args(args, options)?; + Ok(wrap(andop(vec![ lteop(t.left_start, t.right_end), gteop(t.left_end, t.right_start), - ])) + ]))) } - _ => func(&op_str, a), + _ => func_with_options(&op_str, a, options), } } - }) - } - - /// Converts the expression to a SQL string. - fn to_sql(&self) -> Result { - let ast = self.to_sql_ast()?; - Ok(ast.to_string()) + } } } #[cfg(test)] mod tests { - use super::ToSqlAst; + use super::{NameKind, ToSqlAst, ToSqlOptions}; use crate::Expr; + use serde_json::json; #[test] fn test_basic_expression() { @@ -443,4 +631,43 @@ mod tests { let sql_str = sql_ast.to_string(); assert_eq!(sql_str, "ts_start < CAST('2020-02-01' AS DATE)"); } + + #[test] + fn test_property_resolver_callback() { + let expr: Expr = "collection = 'landsat'".parse().unwrap(); + let resolver = |name: &str, kind: NameKind| match (kind, name) { + (NameKind::Property, "collection") => Some("payload ->> 'collection'".to_string()), + _ => None, + }; + let sql = expr + .to_sql_with_options(ToSqlOptions::with_callback(&resolver)) + .unwrap(); + assert_eq!(sql, "payload ->> 'collection' = 'landsat'"); + } + + #[test] + fn test_property_resolver_json() { + let mapping = json!({ + "properties": {"collection": "payload ->> 'collection'"} + }); + let map = mapping.as_object().unwrap(); + let expr: Expr = "collection = 'landsat'".parse().unwrap(); + let sql = expr + .to_sql_with_options(ToSqlOptions::with_json(map)) + .unwrap(); + assert_eq!(sql, "payload ->> 'collection' = 'landsat'"); + } + + #[test] + fn test_function_resolver_json() { + let mapping = json!({ + "functions": {"lower": "custom.lower"} + }); + let map = mapping.as_object().unwrap(); + let expr: Expr = "casei(name)".parse().unwrap(); + let sql = expr + .to_sql_with_options(ToSqlOptions::with_json(map)) + .unwrap(); + assert_eq!(sql, "custom.lower(name)"); + } }