diff --git a/Cargo.lock b/Cargo.lock index 5cbc317c..d59e7ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,7 +678,7 @@ name = "cql2-wasm" version = "0.5.3" dependencies = [ "cql2", - "getrandom 0.4.1", + "getrandom 0.3.4", "js-sys", "serde-wasm-bindgen", "serde_json", @@ -1135,21 +1135,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasip3", - "wasm-bindgen", -] - [[package]] name = "glob" version = "0.3.3" @@ -1519,12 +1504,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -1554,8 +1533,6 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", - "serde", - "serde_core", ] [[package]] @@ -1709,12 +1686,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lexical-core" version = "1.0.6" @@ -2199,16 +2170,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.114", -] - [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -3439,12 +3400,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unindent" version = "0.2.4" @@ -3554,15 +3509,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.111" @@ -3661,40 +3607,6 @@ version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0000397743a3b549ddba01befd1a26020eff98a028429630281c4203b4cc538d" -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" version = "0.3.88" @@ -3971,88 +3883,6 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.114", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] [[package]] name = "wkt" diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 6f72e1f5..a2fddd54 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use clap::{ArgAction, Parser, ValueEnum}; -use cql2::{Expr, ToSqlAst, Validator}; +use cql2::{Expr, ToElasticsearch, ToSqlAst, Validator}; use std::io::Read; /// The CQL2 command-line interface. @@ -69,6 +69,12 @@ enum OutputFormat { /// SQL Sql, + + /// Elasticsearch DSL, pretty-printed + ElasticsearchPretty, + + /// Elasticsearch DSL, compact + Elasticsearch, } impl Cli { @@ -173,6 +179,14 @@ impl Cli { let sql_ast = expr.to_sql_ast()?; println!("{}", sql_ast); } + OutputFormat::ElasticsearchPretty => { + let dsl = expr.to_elasticsearch()?; + serde_json::to_writer_pretty(std::io::stdout(), &dsl)?; + } + OutputFormat::Elasticsearch => { + let dsl = expr.to_elasticsearch()?; + serde_json::to_writer(std::io::stdout(), &dsl)?; + } } println!(); Ok(()) diff --git a/src/elasticsearch.rs b/src/elasticsearch.rs new file mode 100644 index 00000000..139c896b --- /dev/null +++ b/src/elasticsearch.rs @@ -0,0 +1,1015 @@ +use crate::{Error, Expr, Geometry}; +use serde_json::{json, Value}; + +/// Trait for converting CQL2 expressions to Elasticsearch DSL. +pub trait ToElasticsearch { + /// Converts this expression to an Elasticsearch DSL query object. + /// + /// Returns a [`serde_json::Value`] that represents the Elasticsearch Query DSL. + /// + /// # Examples + /// + /// ``` + /// use cql2::Expr; + /// use cql2::ToElasticsearch; + /// + /// let expr: Expr = "landsat:scene_id = 'LC82030282019133LGN00'".parse().unwrap(); + /// let dsl = expr.to_elasticsearch().unwrap(); + /// assert_eq!(dsl, serde_json::json!({"term": {"landsat:scene_id": "LC82030282019133LGN00"}})); + /// ``` + fn to_elasticsearch(&self) -> Result; +} + +/// Converts a CQL2 `LIKE` pattern to an Elasticsearch wildcard pattern. +/// +/// CQL2 uses SQL-style wildcards: `%` for any sequence of characters and +/// `_` for any single character. Elasticsearch uses `*` and `?` respectively. +/// Existing `*` and `?` characters in the pattern are escaped with a backslash. +fn like_to_wildcard(pattern: &str) -> String { + let mut result = String::with_capacity(pattern.len()); + for c in pattern.chars() { + match c { + '%' => result.push('*'), + '_' => result.push('?'), + '*' | '?' => { + result.push('\\'); + result.push(c); + } + _ => result.push(c), + } + } + result +} + +/// Attempts to extract a property name from an expression, handling `casei`/`accenti` wrappers. +/// +/// Returns `Some((property_name, case_insensitive))` if the expression is a property reference +/// (optionally wrapped in `casei` or `accenti`). The `case_insensitive` flag is `true` only +/// for `casei`; `accenti` is recognized but does not set `case_insensitive` because +/// Elasticsearch's `case_insensitive` parameter handles character case only, not diacritics. +/// Accent-insensitive matching in Elasticsearch requires a custom analyzer. +fn extract_property(expr: &Expr) -> Option<(&str, bool)> { + match expr { + Expr::Property { property } => Some((property.as_str(), false)), + Expr::Operation { op, args } if args.len() == 1 => { + let op_lower = op.to_lowercase(); + if op_lower == "casei" || op_lower == "accenti" { + if let Expr::Property { property } = args[0].as_ref() { + Some((property.as_str(), op_lower == "casei")) + } else { + None + } + } else { + None + } + } + _ => None, + } +} + +/// Converts a scalar CQL2 expression to a JSON value for use in Elasticsearch queries. +fn scalar_value(expr: &Expr) -> Result { + match expr { + Expr::Float(v) => Ok(json!(*v)), + Expr::Literal(v) => Ok(json!(v)), + Expr::Bool(v) => Ok(json!(*v)), + Expr::Null => Ok(Value::Null), + Expr::Timestamp { timestamp } => match timestamp.as_ref() { + Expr::Literal(v) => Ok(json!(v)), + _ => Ok(json!(timestamp.to_text()?)), + }, + Expr::Date { date } => match date.as_ref() { + Expr::Literal(v) => Ok(json!(v)), + _ => Ok(json!(date.to_text()?)), + }, + _ => Err(Error::OperationError()), + } +} + +/// Extracts a field name and value from a two-argument comparison expression. +/// +/// The property may be on either the left or right side. Returns +/// `(field, case_insensitive, value, flipped)` where `flipped` is `true` when +/// the property was the right-hand argument (so range comparison direction should +/// be reversed). +fn comparison_args(args: &[Box]) -> Result<(String, bool, Value, bool), Error> { + if let Some((field, ci)) = extract_property(args[0].as_ref()) { + let value = scalar_value(args[1].as_ref())?; + Ok((field.to_string(), ci, value, false)) + } else if let Some((field, ci)) = extract_property(args[1].as_ref()) { + let value = scalar_value(args[0].as_ref())?; + Ok((field.to_string(), ci, value, true)) + } else { + Err(Error::OperationError()) + } +} + +/// Builds an Elasticsearch `geo_shape` query for the given field, geometry, and relation. +fn geo_shape_query(field: &str, geometry: &Geometry, relation: &str) -> Result { + // Both GeoJSON and WKT Geometry variants serialize to GeoJSON via the custom serializer. + let shape = serde_json::to_value(geometry)?; + Ok(json!({ + "geo_shape": { + field: { + "shape": shape, + "relation": relation + } + } + })) +} + +/// Builds an Elasticsearch `geo_shape` query from two spatial-operator arguments. +/// +/// Determines which argument is the property (field) and which is the geometry, +/// accepting either order. +fn spatial_args_query(args: &[Box], relation: &str) -> Result { + let (field, geom_expr) = + if let Some((prop, _)) = extract_property(args[0].as_ref()) { + (prop, args[1].as_ref()) + } else if let Some((prop, _)) = extract_property(args[1].as_ref()) { + (prop, args[0].as_ref()) + } else { + return Err(Error::OperationError()); + }; + + match geom_expr { + Expr::Geometry(g) => geo_shape_query(field, g, relation), + Expr::BBox { bbox } => bbox_envelope_query(field, bbox, relation), + // `BBOX(...)` in CQL2 text form is parsed as Operation { op: "bbox", args } + Expr::Operation { op, args } if op.to_lowercase() == "bbox" => { + bbox_envelope_query(field, args, relation) + } + _ => Err(Error::OperationError()), + } +} + +/// Builds an Elasticsearch `geo_shape` envelope query from bbox coordinates. +fn bbox_envelope_query(field: &str, bbox: &[Box], relation: &str) -> Result { + let (minx, miny, maxx, maxy) = extract_bbox_coords(bbox)?; + Ok(json!({ + "geo_shape": { + field: { + "shape": { + "type": "envelope", + "coordinates": [[minx, maxy], [maxx, miny]] + }, + "relation": relation + } + } + })) +} + +/// Extracts 2D bounding box coordinates `(minx, miny, maxx, maxy)` from a CQL2 bbox array. +fn extract_bbox_coords(bbox: &[Box]) -> Result<(f64, f64, f64, f64), Error> { + let get_float = |expr: &Expr| -> Result { + match expr { + Expr::Float(v) => Ok(*v), + Expr::Literal(v) => v.parse().map_err(Error::from), + _ => Err(Error::OperationError()), + } + }; + match bbox.len() { + 4 => Ok(( + get_float(bbox[0].as_ref())?, + get_float(bbox[1].as_ref())?, + get_float(bbox[2].as_ref())?, + get_float(bbox[3].as_ref())?, + )), + 6 => Ok(( + get_float(bbox[0].as_ref())?, + get_float(bbox[1].as_ref())?, + get_float(bbox[3].as_ref())?, + get_float(bbox[4].as_ref())?, + )), + _ => Err(Error::OperationError()), + } +} + +/// Extracts a temporal string value from a timestamp, date, or literal expression. +fn temporal_value(expr: &Expr) -> Result { + match expr { + Expr::Timestamp { timestamp } => match timestamp.as_ref() { + Expr::Literal(v) => Ok(json!(v)), + _ => Ok(json!(timestamp.to_text()?)), + }, + Expr::Date { date } => match date.as_ref() { + Expr::Literal(v) => Ok(json!(v)), + _ => Ok(json!(date.to_text()?)), + }, + Expr::Literal(v) => Ok(json!(v)), + _ => Err(Error::OperationError()), + } +} + +/// Extracts the `(start, end)` temporal extent from an expression. +/// +/// For interval expressions, returns the start and end values. +/// For point-in-time expressions (timestamp, date, literal), returns the same +/// value for both start and end. +fn temporal_extent(expr: &Expr) -> Result<(Value, Value), Error> { + match expr { + Expr::Interval { interval } => { + let start = temporal_value(interval[0].as_ref())?; + let end = temporal_value(interval[1].as_ref())?; + Ok((start, end)) + } + _ => { + let v = temporal_value(expr)?; + Ok((v.clone(), v)) + } + } +} + +impl ToElasticsearch for Expr { + /// Converts this expression to an Elasticsearch DSL query. + /// + /// # Supported operators + /// + /// - **Boolean**: `AND`, `OR`, `NOT` + /// - **Comparison**: `=`, `<>`, `>`, `>=`, `<`, `<=` + /// - **Null check**: `IS NULL` + /// - **Pattern match**: `LIKE` (with optional `casei`/`accenti` wrappers) + /// - **Membership**: `IN` + /// - **Range**: `BETWEEN` + /// - **Spatial**: `S_INTERSECTS`, `S_DISJOINT`, `S_WITHIN`, `S_CONTAINS`, + /// `S_EQUALS`, `S_TOUCHES`, `S_OVERLAPS`, `S_CROSSES` + /// - **Temporal**: `T_BEFORE`, `T_AFTER`, `T_MEETS`, `T_METBY`, + /// `T_OVERLAPS`, `T_OVERLAPPEDBY`, `T_STARTS`, `T_STARTEDBY`, + /// `T_DURING`, `T_CONTAINS`, `T_FINISHES`, `T_FINISHEDBY`, + /// `T_EQUALS`, `T_DISJOINT`, `T_INTERSECTS`, `ANYINTERACTS` + /// + /// # Examples + /// + /// ``` + /// use cql2::Expr; + /// use cql2::ToElasticsearch; + /// + /// let expr: Expr = "eo:cloud_cover < 0.5".parse().unwrap(); + /// let dsl = expr.to_elasticsearch().unwrap(); + /// assert_eq!(dsl, serde_json::json!({"range": {"eo:cloud_cover": {"lt": 0.5}}})); + /// ``` + fn to_elasticsearch(&self) -> Result { + match self { + Expr::Bool(true) => Ok(json!({"match_all": {}})), + Expr::Bool(false) => Ok(json!({"match_none": {}})), + Expr::Operation { op, args } => { + let op_lower = op.to_lowercase(); + match op_lower.as_str() { + "and" => { + let must: Vec = args + .iter() + .map(|a| a.to_elasticsearch()) + .collect::>()?; + Ok(json!({"bool": {"must": must}})) + } + "or" => { + let should: Vec = args + .iter() + .map(|a| a.to_elasticsearch()) + .collect::>()?; + Ok(json!({"bool": {"should": should}})) + } + "not" => { + let inner = args[0].to_elasticsearch()?; + Ok(json!({"bool": {"must_not": [inner]}})) + } + "isnull" => { + let (field, _) = extract_property(args[0].as_ref()) + .ok_or_else(|| Error::OperationError())?; + Ok(json!({"bool": {"must_not": [{"exists": {"field": field}}]}})) + } + "between" => { + let (field, _) = extract_property(args[0].as_ref()) + .ok_or_else(|| Error::OperationError())?; + let low = scalar_value(args[1].as_ref())?; + let high = scalar_value(args[2].as_ref())?; + Ok(json!({"range": {field: {"gte": low, "lte": high}}})) + } + "like" => { + let case_insensitive = matches!( + args[0].as_ref(), + Expr::Operation { op, .. } if op.to_lowercase() == "casei" + ); + let (field, _) = extract_property(args[0].as_ref()) + .ok_or_else(|| Error::OperationError())?; + let pattern = match args[1].as_ref() { + Expr::Literal(v) => like_to_wildcard(v), + _ => return Err(Error::OperationError()), + }; + if case_insensitive { + Ok(json!({"wildcard": {field: {"value": pattern, "case_insensitive": true}}})) + } else { + Ok(json!({"wildcard": {field: {"value": pattern}}})) + } + } + "in" => { + let (field, _) = extract_property(args[0].as_ref()) + .ok_or_else(|| Error::OperationError())?; + let values: Vec = match args[1].as_ref() { + Expr::Array(items) => items + .iter() + .map(|item| scalar_value(item.as_ref())) + .collect::>()?, + _ => return Err(Error::OperationError()), + }; + Ok(json!({"terms": {field: values}})) + } + "=" | "eq" | "a_equals" => { + let (field, ci, value, _) = comparison_args(args)?; + if ci { + Ok(json!({"term": {field: {"value": value, "case_insensitive": true}}})) + } else { + Ok(json!({"term": {field: value}})) + } + } + "<>" | "ne" => { + let (field, ci, value, _) = comparison_args(args)?; + if ci { + Ok(json!({"bool": {"must_not": [{"term": {field: {"value": value, "case_insensitive": true}}}]}})) + } else { + Ok(json!({"bool": {"must_not": [{"term": {field: value}}]}})) + } + } + ">" | "gt" => { + let (field, _, value, flipped) = comparison_args(args)?; + let range_op = if flipped { "lt" } else { "gt" }; + Ok(json!({"range": {field: {range_op: value}}})) + } + ">=" | "ge" | "gte" => { + let (field, _, value, flipped) = comparison_args(args)?; + let range_op = if flipped { "lte" } else { "gte" }; + Ok(json!({"range": {field: {range_op: value}}})) + } + "<" | "lt" => { + let (field, _, value, flipped) = comparison_args(args)?; + let range_op = if flipped { "gt" } else { "lt" }; + Ok(json!({"range": {field: {range_op: value}}})) + } + "<=" | "le" | "lte" => { + let (field, _, value, flipped) = comparison_args(args)?; + let range_op = if flipped { "gte" } else { "lte" }; + Ok(json!({"range": {field: {range_op: value}}})) + } + "s_intersects" | "st_intersects" | "intersects" => { + spatial_args_query(args, "intersects") + } + "s_within" | "st_within" => spatial_args_query(args, "within"), + "s_contains" | "st_contains" => spatial_args_query(args, "contains"), + "s_disjoint" | "st_disjoint" => { + let inner = spatial_args_query(args, "intersects")?; + Ok(json!({"bool": {"must_not": [inner]}})) + } + "s_equals" | "st_equals" => { + // Two shapes are equal iff each is within the other. + // Elasticsearch has no native "equals" geo_shape relation, so + // we express it as the conjunction of "within" and "contains". + let within = spatial_args_query(args, "within")?; + let contains = spatial_args_query(args, "contains")?; + Ok(json!({"bool": {"must": [within, contains]}})) + } + // NOTE: Elasticsearch does not expose `touches`, `overlaps`, or + // `crosses` geo_shape relations. The queries below are approximated + // as `intersects`, which is a superset of each of those relations. + "s_touches" | "st_touches" => spatial_args_query(args, "intersects"), + "s_overlaps" | "st_overlaps" => spatial_args_query(args, "intersects"), + "s_crosses" | "st_crosses" => spatial_args_query(args, "intersects"), + // Temporal operators: convert to range queries on the property field. + // When the property is an interval field, the implementation is an approximation. + "t_before" => { + // t_before(A, B): end(A) < start(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, _end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"lt": start}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (_start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gt": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_after" => { + // t_after(A, B): start(A) > end(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (_start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gt": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, _end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"lt": start}}})) + } else { + Err(Error::OperationError()) + } + } + "t_meets" => { + // t_meets(A, B): end(A) = start(B); approximate as A < start(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, _end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"lt": start}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (_start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gt": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_metby" => { + // t_metby(A, B): start(A) = end(B); approximate as A > end(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (_start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gt": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, _end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"lt": start}}})) + } else { + Err(Error::OperationError()) + } + } + "t_during" => { + // t_during(A, B): start(B) < A < end(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gt": start, "lt": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gt": start, "lt": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_contains" => { + // t_contains(A, B): A contains B (B is during A); approximate as range of B within A + if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_starts" => { + // t_starts(A, B): start(A) = start(B) AND end(A) < end(B). + // For a point-in-time property A, approximate as: start(B) <= A < end(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lt": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lt": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_startedby" => { + // t_startedby(A, B): B starts A, i.e. start(A) = start(B) AND end(B) < end(A). + // For a point-in-time property A, approximate as: A = start(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, _end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"term": {field: start}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, _end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"term": {field: start}})) + } else { + Err(Error::OperationError()) + } + } + "t_finishes" => { + // t_finishes(A, B): end(A) = end(B) AND start(A) > start(B). + // For a point-in-time property A, approximate as: start(B) < A <= end(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gt": start, "lte": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gt": start, "lte": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_finishedby" => { + // t_finishedby(A, B): end(B) = end(A) AND start(B) > start(A). + // For a point-in-time property A, approximate as: A = end(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (_start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"term": {field: end}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (_start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"term": {field: end}})) + } else { + Err(Error::OperationError()) + } + } + "t_overlaps" => { + // t_overlaps(A, B): start(A) < start(B) < end(A) < end(B). + // A starts before B and they overlap on the trailing end of A. + // For a point-in-time property A, approximate as: start(B) <= A <= end(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_overlappedby" => { + // t_overlappedby(A, B): start(B) < start(A) < end(B) < end(A). + // A is overlapped at its start by B; A extends past the end of B. + // For a point-in-time property A, approximate as: start(B) <= A <= end(B). + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else { + Err(Error::OperationError()) + } + } + "t_equals" => { + // t_equals(A, B): start(A) = start(B) AND end(A) = end(B) + // For a datetime property, approximate as an exact term match + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let v = temporal_value(args[1].as_ref())?; + Ok(json!({"term": {field: v}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let v = temporal_value(args[0].as_ref())?; + Ok(json!({"term": {field: v}})) + } else { + Err(Error::OperationError()) + } + } + "t_disjoint" => { + // t_disjoint: NOT t_intersects + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"bool": {"must_not": [{"range": {field: {"gte": start, "lte": end}}}]}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"bool": {"must_not": [{"range": {field: {"gte": start, "lte": end}}}]}})) + } else { + Err(Error::OperationError()) + } + } + "t_intersects" | "anyinteracts" => { + // t_intersects(A, B): start(A) <= end(B) AND end(A) >= start(B) + if let Some((field, _)) = extract_property(args[0].as_ref()) { + let (start, end) = temporal_extent(args[1].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else if let Some((field, _)) = extract_property(args[1].as_ref()) { + let (start, end) = temporal_extent(args[0].as_ref())?; + Ok(json!({"range": {field: {"gte": start, "lte": end}}})) + } else { + Err(Error::OperationError()) + } + } + _ => Err(Error::OpNotImplemented("elasticsearch")), + } + } + _ => Err(Error::OpNotImplemented("elasticsearch")), + } + } +} + +#[cfg(test)] +mod tests { + use super::ToElasticsearch; + use crate::Expr; + use serde_json::json; + + #[test] + fn test_eq_string() { + let expr: Expr = "landsat:scene_id = 'LC82030282019133LGN00'".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"term": {"landsat:scene_id": "LC82030282019133LGN00"}}) + ); + } + + #[test] + fn test_eq_number() { + let expr: Expr = "eo:cloud_cover = 0.1".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"term": {"eo:cloud_cover": 0.1}})); + } + + #[test] + fn test_ne() { + let expr: Expr = "eo:cloud_cover <> 0.1".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"bool": {"must_not": [{"term": {"eo:cloud_cover": 0.1}}]}}) + ); + } + + #[test] + fn test_gt() { + let expr: Expr = "eo:cloud_cover > 0.1".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"range": {"eo:cloud_cover": {"gt": 0.1}}})); + } + + #[test] + fn test_gte() { + let expr: Expr = "eo:cloud_cover >= 0.1".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"range": {"eo:cloud_cover": {"gte": 0.1}}})); + } + + #[test] + fn test_lt() { + let expr: Expr = "eo:cloud_cover < 0.5".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"range": {"eo:cloud_cover": {"lt": 0.5}}})); + } + + #[test] + fn test_lte() { + let expr: Expr = "eo:cloud_cover <= 0.5".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"range": {"eo:cloud_cover": {"lte": 0.5}}})); + } + + #[test] + fn test_range_flipped() { + // Value on the left side: `0.1 < eo:cloud_cover` means `eo:cloud_cover > 0.1` + let expr: Expr = serde_json::from_str::( + r#"{"op":"<","args":[0.1,{"property":"eo:cloud_cover"}]}"#, + ) + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"range": {"eo:cloud_cover": {"gt": 0.1}}})); + } + + #[test] + fn test_and() { + let expr: Expr = "beamMode = 'ScanSAR' AND swathDirection = 'ascending'" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "bool": { + "must": [ + {"term": {"beamMode": "ScanSAR"}}, + {"term": {"swathDirection": "ascending"}} + ] + } + }) + ); + } + + #[test] + fn test_or() { + let expr: Expr = "eo:cloud_cover = 0.1 OR eo:cloud_cover = 0.2" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "bool": { + "should": [ + {"term": {"eo:cloud_cover": 0.1}}, + {"term": {"eo:cloud_cover": 0.2}} + ] + } + }) + ); + } + + #[test] + fn test_not() { + let expr: Expr = "NOT landsat:scene_id = 'LC82030282019133LGN00'" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "bool": { + "must_not": [ + {"term": {"landsat:scene_id": "LC82030282019133LGN00"}} + ] + } + }) + ); + } + + #[test] + fn test_is_null() { + let expr: Expr = "eo:cloud_cover IS NULL".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"bool": {"must_not": [{"exists": {"field": "eo:cloud_cover"}}]}}) + ); + } + + #[test] + fn test_between() { + let expr: Expr = "eo:cloud_cover BETWEEN 0 AND 0.5".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"range": {"eo:cloud_cover": {"gte": 0.0, "lte": 0.5}}}) + ); + } + + #[test] + fn test_like() { + let expr: Expr = "eo:instrument LIKE 'OLI%'".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"wildcard": {"eo:instrument": {"value": "OLI*"}}}) + ); + } + + #[test] + fn test_like_underscore() { + let expr: Expr = "name LIKE 'ab_def'".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!(dsl, json!({"wildcard": {"name": {"value": "ab?def"}}})); + } + + #[test] + fn test_like_casei() { + let expr: Expr = "casei(eo:instrument) LIKE 'oli%'".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"wildcard": {"eo:instrument": {"value": "oli*", "case_insensitive": true}}}) + ); + } + + #[test] + fn test_in() { + let expr: Expr = "vehicle:fuel IN ('petrol', 'diesel')".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"terms": {"vehicle:fuel": ["petrol", "diesel"]}}) + ); + } + + #[test] + fn test_bool_true() { + let expr = Expr::Bool(true); + assert_eq!(expr.to_elasticsearch().unwrap(), json!({"match_all": {}})); + } + + #[test] + fn test_bool_false() { + let expr = Expr::Bool(false); + assert_eq!(expr.to_elasticsearch().unwrap(), json!({"match_none": {}})); + } + + #[test] + fn test_casei_eq() { + let expr: Expr = "casei(eo:instrument) = 'oli_tirs'".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"term": {"eo:instrument": {"value": "oli_tirs", "case_insensitive": true}}}) + ); + } + + #[test] + fn test_s_intersects_geojson() { + let expr: Expr = serde_json::from_str::( + r#"{ + "op": "s_intersects", + "args": [ + {"property": "footprint"}, + {"type": "Point", "coordinates": [0.0, 0.0]} + ] + }"#, + ) + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "geo_shape": { + "footprint": { + "shape": {"type": "Point", "coordinates": [0.0, 0.0]}, + "relation": "intersects" + } + } + }) + ); + } + + #[test] + fn test_s_within() { + let expr: Expr = serde_json::from_str::( + r#"{ + "op": "s_within", + "args": [ + {"property": "location"}, + {"type": "Polygon", "coordinates": [[[0,0],[1,0],[1,1],[0,1],[0,0]]]} + ] + }"#, + ) + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl["geo_shape"]["location"]["relation"], + json!("within") + ); + } + + #[test] + fn test_s_disjoint() { + let expr: Expr = serde_json::from_str::( + r#"{ + "op": "s_disjoint", + "args": [ + {"property": "footprint"}, + {"type": "Point", "coordinates": [0.0, 0.0]} + ] + }"#, + ) + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + // Disjoint = NOT intersects + assert!(dsl["bool"]["must_not"].is_array()); + assert_eq!( + dsl["bool"]["must_not"][0]["geo_shape"]["footprint"]["relation"], + json!("intersects") + ); + } + + #[test] + fn test_s_equals() { + let expr: Expr = serde_json::from_str::( + r#"{ + "op": "s_equals", + "args": [ + {"property": "footprint"}, + {"type": "Point", "coordinates": [0.0, 0.0]} + ] + }"#, + ) + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + // s_equals = within AND contains + assert!(dsl["bool"]["must"].is_array()); + assert_eq!(dsl["bool"]["must"][0]["geo_shape"]["footprint"]["relation"], json!("within")); + assert_eq!(dsl["bool"]["must"][1]["geo_shape"]["footprint"]["relation"], json!("contains")); + } + + #[test] + fn test_s_intersects_bbox() { + let expr: Expr = "s_intersects(footprint, BBOX(0, 0, 1, 1))".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "geo_shape": { + "footprint": { + "shape": { + "type": "envelope", + "coordinates": [[0.0, 1.0], [1.0, 0.0]] + }, + "relation": "intersects" + } + } + }) + ); + } + + #[test] + fn test_t_before() { + let expr: Expr = + "t_before(datetime, TIMESTAMP('2020-01-01T00:00:00Z'))".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"range": {"datetime": {"lt": "2020-01-01T00:00:00Z"}}}) + ); + } + + #[test] + fn test_t_after() { + let expr: Expr = + "t_after(datetime, TIMESTAMP('2020-01-01T00:00:00Z'))".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"range": {"datetime": {"gt": "2020-01-01T00:00:00Z"}}}) + ); + } + + #[test] + fn test_t_intersects() { + let expr: Expr = + "t_intersects(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "range": { + "datetime": { + "gte": "2020-01-01T00:00:00Z", + "lte": "2021-01-01T00:00:00Z" + } + } + }) + ); + } + + #[test] + fn test_t_during() { + let expr: Expr = + "t_during(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "range": { + "datetime": { + "gt": "2020-01-01T00:00:00Z", + "lt": "2021-01-01T00:00:00Z" + } + } + }) + ); + } + + #[test] + fn test_t_disjoint() { + let expr: Expr = + "t_disjoint(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({ + "bool": { + "must_not": [{ + "range": { + "datetime": { + "gte": "2020-01-01T00:00:00Z", + "lte": "2021-01-01T00:00:00Z" + } + } + }] + } + }) + ); + } + + #[test] + fn test_t_equals() { + let expr: Expr = + "t_equals(datetime, TIMESTAMP('2020-06-15T00:00:00Z'))".parse().unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + assert_eq!( + dsl, + json!({"term": {"datetime": "2020-06-15T00:00:00Z"}}) + ); + } + + #[test] + fn test_t_startedby() { + let expr: Expr = + "t_startedby(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + // startedby: property ≈ start of reference interval + assert_eq!(dsl, json!({"term": {"datetime": "2020-01-01T00:00:00Z"}})); + } + + #[test] + fn test_t_finishedby() { + let expr: Expr = + "t_finishedby(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z'))" + .parse() + .unwrap(); + let dsl = expr.to_elasticsearch().unwrap(); + // finishedby: property ≈ end of reference interval + assert_eq!(dsl, json!({"term": {"datetime": "2021-01-01T00:00:00Z"}})); + } + + #[test] + fn test_like_to_wildcard() { + use super::like_to_wildcard; + assert_eq!(like_to_wildcard("OLI%"), "OLI*"); + assert_eq!(like_to_wildcard("ab_def"), "ab?def"); + assert_eq!(like_to_wildcard("exact"), "exact"); + assert_eq!(like_to_wildcard("has*star"), "has\\*star"); + assert_eq!(like_to_wildcard("has?q"), "has\\?q"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 56a5d69e..8d59dfeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ #![allow(clippy::result_large_err)] mod duckdb; +mod elasticsearch; mod error; mod expr; mod geometry; @@ -40,6 +41,7 @@ mod temporal; mod validator; pub use duckdb::ToDuckSQL; +pub use elasticsearch::ToElasticsearch; pub use error::Error; pub use expr::*; pub use geometry::{spatial_op, Geometry}; diff --git a/tests/elasticsearch_expected.txt b/tests/elasticsearch_expected.txt new file mode 100644 index 00000000..fc00c136 --- /dev/null +++ b/tests/elasticsearch_expected.txt @@ -0,0 +1,34 @@ +landsat:scene_id = 'LC82030282019133LGN00' +{"term":{"landsat:scene_id":"LC82030282019133LGN00"}} +eo:cloud_cover < 0.5 +{"range":{"eo:cloud_cover":{"lt":0.5}}} +eo:cloud_cover > 10 +{"range":{"eo:cloud_cover":{"gt":10.0}}} +eo:cloud_cover >= 10 +{"range":{"eo:cloud_cover":{"gte":10.0}}} +eo:cloud_cover <= 10 +{"range":{"eo:cloud_cover":{"lte":10.0}}} +eo:cloud_cover <> 10 +{"bool":{"must_not":[{"term":{"eo:cloud_cover":10.0}}]}} +eo:cloud_cover BETWEEN 0 AND 50 +{"range":{"eo:cloud_cover":{"gte":0.0,"lte":50.0}}} +eo:instrument LIKE 'OLI%' +{"wildcard":{"eo:instrument":{"value":"OLI*"}}} +eo:cloud_cover = 0.1 OR eo:cloud_cover = 0.2 +{"bool":{"should":[{"term":{"eo:cloud_cover":0.1}},{"term":{"eo:cloud_cover":0.2}}]}} +beamMode = 'ScanSAR Narrow' AND swathDirection = 'ascending' +{"bool":{"must":[{"term":{"beamMode":"ScanSAR Narrow"}},{"term":{"swathDirection":"ascending"}}]}} +NOT landsat:scene_id = 'LC82030282019133LGN00' +{"bool":{"must_not":[{"term":{"landsat:scene_id":"LC82030282019133LGN00"}}]}} +eo:cloud_cover IS NULL +{"bool":{"must_not":[{"exists":{"field":"eo:cloud_cover"}}]}} +vehicle:fuel IN ('petrol', 'diesel') +{"terms":{"vehicle:fuel":["petrol","diesel"]}} +t_before(datetime, TIMESTAMP('2021-01-01T00:00:00Z')) +{"range":{"datetime":{"lt":"2021-01-01T00:00:00Z"}}} +t_after(datetime, TIMESTAMP('2020-01-01T00:00:00Z')) +{"range":{"datetime":{"gt":"2020-01-01T00:00:00Z"}}} +t_during(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z')) +{"range":{"datetime":{"gt":"2020-01-01T00:00:00Z","lt":"2021-01-01T00:00:00Z"}}} +t_intersects(datetime, INTERVAL('2020-01-01T00:00:00Z','2021-01-01T00:00:00Z')) +{"range":{"datetime":{"gte":"2020-01-01T00:00:00Z","lte":"2021-01-01T00:00:00Z"}}} diff --git a/tests/elasticsearch_tests.rs b/tests/elasticsearch_tests.rs new file mode 100644 index 00000000..3183d215 --- /dev/null +++ b/tests/elasticsearch_tests.rs @@ -0,0 +1,32 @@ +use assert_json_diff::assert_json_eq; +use cql2::{Expr, ToElasticsearch}; +use std::path::Path; + +fn read_lines(filename: impl AsRef) -> Vec { + std::fs::read_to_string(filename) + .unwrap() + .lines() + .map(String::from) + .collect() +} + +/// Reads pairs of lines from `tests/elasticsearch_expected.txt` where: +/// - Line N: CQL2 input expression (text or JSON) +/// - Line N+1: expected Elasticsearch DSL compact JSON +#[test] +fn validate_elasticsearch_fixtures() { + let lines = read_lines("tests/elasticsearch_expected.txt"); + let inputs = lines.clone().into_iter().step_by(2); + let expecteds = lines.clone().into_iter().skip(1).step_by(2); + for (input, expected_json_str) in inputs.zip(expecteds) { + let expr: Expr = input + .parse() + .unwrap_or_else(|e| panic!("Failed to parse CQL2 '{input}': {e}")); + let dsl = expr + .to_elasticsearch() + .unwrap_or_else(|e| panic!("to_elasticsearch failed for '{input}': {e}")); + let expected: serde_json::Value = serde_json::from_str(&expected_json_str) + .unwrap_or_else(|e| panic!("Invalid expected JSON for '{input}': {e}")); + assert_json_eq!(dsl, expected); + } +}