diff --git a/crates/integration-tests/src/tests/snapshots/integration_tests__tests__basic__runs_a_query.snap b/crates/integration-tests/src/tests/snapshots/integration_tests__tests__basic__runs_a_query.snap index b90d3938..65c13270 100644 --- a/crates/integration-tests/src/tests/snapshots/integration_tests__tests__basic__runs_a_query.snap +++ b/crates/integration-tests/src/tests/snapshots/integration_tests__tests__basic__runs_a_query.snap @@ -6,52 +6,42 @@ data: movies: - title: Blacksmith Scene imdb: - rating: - $numberDouble: "6.2" + rating: 6.2 votes: 1189 - title: The Great Train Robbery imdb: - rating: - $numberDouble: "7.4" + rating: 7.4 votes: 9847 - title: The Land Beyond the Sunset imdb: - rating: - $numberDouble: "7.1" + rating: 7.1 votes: 448 - title: A Corner in Wheat imdb: - rating: - $numberDouble: "6.6" + rating: 6.6 votes: 1375 - title: "Winsor McCay, the Famous Cartoonist of the N.Y. Herald and His Moving Comics" imdb: - rating: - $numberDouble: "7.3" + rating: 7.3 votes: 1034 - title: Traffic in Souls imdb: - rating: - $numberInt: "6" + rating: 6 votes: 371 - title: Gertie the Dinosaur imdb: - rating: - $numberDouble: "7.3" + rating: 7.3 votes: 1837 - title: In the Land of the Head Hunters imdb: - rating: - $numberDouble: "5.8" + rating: 5.8 votes: 223 - title: The Perils of Pauline imdb: - rating: - $numberDouble: "7.6" + rating: 7.6 votes: 744 - title: The Birth of a Nation imdb: - rating: - $numberDouble: "6.8" + rating: 6.8 votes: 15715 errors: ~ diff --git a/crates/mongodb-agent-common/src/comparison_function.rs b/crates/mongodb-agent-common/src/comparison_function.rs index 3e7b2dc1..881c0d61 100644 --- a/crates/mongodb-agent-common/src/comparison_function.rs +++ b/crates/mongodb-agent-common/src/comparison_function.rs @@ -57,8 +57,8 @@ impl ComparisonFunction { .ok_or(QueryPlanError::UnknownComparisonOperator(s.to_owned())) } - /// Produce a MongoDB expression that applies this function to the given operands. - pub fn mongodb_expression( + /// Produce a MongoDB expression for use in a match query that applies this function to the given operands. + pub fn mongodb_match_query( self, column_ref: impl Into, comparison_value: Bson, @@ -70,4 +70,22 @@ impl ComparisonFunction { _ => doc! { column_ref: { self.mongodb_name(): comparison_value } }, } } + + /// Produce a MongoDB expression for use in an aggregation expression that applies this + /// function to the given operands. + pub fn mongodb_aggregation_expression( + self, + column_ref: impl Into, + comparison_value: impl Into, + ) -> Document { + match self { + C::Regex => { + doc! { "$regexMatch": { "input": column_ref, "regex": comparison_value } } + } + C::IRegex => { + doc! { "$regexMatch": { "input": column_ref, "regex": comparison_value, "options": "i" } } + } + _ => doc! { self.mongodb_name(): [column_ref, comparison_value] }, + } + } } diff --git a/crates/mongodb-agent-common/src/mongodb/sanitize.rs b/crates/mongodb-agent-common/src/mongodb/sanitize.rs index 0ef537a2..5ac11794 100644 --- a/crates/mongodb-agent-common/src/mongodb/sanitize.rs +++ b/crates/mongodb-agent-common/src/mongodb/sanitize.rs @@ -9,6 +9,8 @@ use crate::interface_types::MongoAgentError; /// Produces a MongoDB expression that references a field by name in a way that is safe from code /// injection. +/// +/// TODO: equivalent to ColumnRef::Expression pub fn get_field(name: &str) -> Document { doc! { "$getField": { "$literal": name } } } @@ -33,10 +35,16 @@ pub fn variable(name: &str) -> Result { } } +/// Returns false if the name contains characters that MongoDB will interpret specially, such as an +/// initial dollar sign, or dots. +pub fn is_name_safe(name: &str) -> bool { + !(name.starts_with('$') || name.contains('.')) +} + /// Given a collection or field name, returns Ok if the name is safe, or Err if it contains /// characters that MongoDB will interpret specially. /// -/// TODO: Can we handle names with dots or dollar signs safely instead of throwing an error? +/// TODO: MDB-159, MBD-160 remove this function in favor of ColumnRef which is infallible pub fn safe_name(name: &str) -> Result, MongoAgentError> { if name.starts_with('$') || name.contains('.') { Err(MongoAgentError::BadQuery(anyhow!("cannot execute query that includes the name, \"{name}\", because it includes characters that MongoDB interperets specially"))) diff --git a/crates/mongodb-agent-common/src/query/column_ref.rs b/crates/mongodb-agent-common/src/query/column_ref.rs new file mode 100644 index 00000000..fd33829e --- /dev/null +++ b/crates/mongodb-agent-common/src/query/column_ref.rs @@ -0,0 +1,363 @@ +use std::{borrow::Cow, iter::once}; + +use mongodb::bson::{doc, Bson}; + +use crate::{mongo_query_plan::ComparisonTarget, mongodb::sanitize::is_name_safe}; + +/// Reference to a document field, or a nested property of a document field. There are two contexts +/// where we reference columns: +/// +/// - match queries, where the reference is a key in the document used in a `$match` aggregation stage +/// - aggregation expressions which appear in a number of contexts +/// +/// Those two contexts are not compatible. For example in aggregation expressions column names are +/// prefixed with a dollar sign ($), but in match queries names are not prefixed. Expressions may +/// reference variables, while match queries may not. Some [ComparisonTarget] values **cannot** be +/// expressed in match queries. Those include root collection column references (which require +/// a variable reference), and columns with names that include characters that MongoDB evaluates +/// specially, such as dollar signs or dots. +/// +/// This type provides a helper that attempts to produce a match query reference for +/// a [ComparisonTarget], but falls back to an aggregation expression if necessary. It is up to the +/// caller to switch contexts in the second case. +#[derive(Clone, Debug, PartialEq)] +pub enum ColumnRef<'a> { + MatchKey(Cow<'a, str>), + Expression(Bson), +} + +impl<'a> ColumnRef<'a> { + /// Given a column target returns a string that can be used in a MongoDB match query that + /// references the corresponding field, either in the target collection of a query request, or + /// in the related collection. Resolves nested fields and root collection references, but does + /// not traverse relationships. + /// + /// If the given target cannot be represented as a match query key, falls back to providing an + /// aggregation expression referencing the column. + pub fn from_comparison_target(column: &ComparisonTarget) -> ColumnRef<'_> { + from_target(column) + } +} + +fn from_target(column: &ComparisonTarget) -> ColumnRef<'_> { + match column { + ComparisonTarget::Column { + name, field_path, .. + } => { + let name_and_path = once(name).chain(field_path.iter().flatten()); + // The None case won't come up if the input to [from_target_helper] has at least + // one element, and we know it does because we start the iterable with `name` + from_path(None, name_and_path).unwrap() + } + ComparisonTarget::RootCollectionColumn { + name, field_path, .. + } => { + // "$$ROOT" is not actually a valid match key, but cheating here makes the + // implementation much simpler. This match branch produces a ColumnRef::Expression + // in all cases. + let init = ColumnRef::MatchKey("$ROOT".into()); + // The None case won't come up if the input to [from_target_helper] has at least + // one element, and we know it does because we start the iterable with `name` + let col_ref = + from_path(Some(init), once(name).chain(field_path.iter().flatten())).unwrap(); + match col_ref { + // move from MatchKey to Expression because "$$ROOT" is not valid in a match key + ColumnRef::MatchKey(key) => ColumnRef::Expression(format!("${key}").into()), + e @ ColumnRef::Expression(_) => e, + } + } + } +} + +fn from_path<'a>( + init: Option>, + path: impl IntoIterator, +) -> Option> { + path.into_iter().fold(init, |accum, element| { + Some(fold_path_element(accum, element)) + }) +} + +fn fold_path_element<'a>( + ref_so_far: Option>, + path_element: &'a str, +) -> ColumnRef<'a> { + match (ref_so_far, is_name_safe(path_element)) { + (Some(ColumnRef::MatchKey(parent)), true) => { + ColumnRef::MatchKey(format!("{parent}.{path_element}").into()) + } + (Some(ColumnRef::MatchKey(parent)), false) => ColumnRef::Expression( + doc! { + "$getField": { + "input": format!("${parent}"), + "field": { "$literal": path_element }, + } + } + .into(), + ), + (Some(ColumnRef::Expression(parent)), true) => ColumnRef::Expression( + doc! { + "$getField": { + "input": parent, + "field": path_element, + } + } + .into(), + ), + (Some(ColumnRef::Expression(parent)), false) => ColumnRef::Expression( + doc! { + "$getField": { + "input": parent, + "field": { "$literal": path_element }, + } + } + .into(), + ), + (None, true) => ColumnRef::MatchKey(path_element.into()), + (None, false) => ColumnRef::Expression( + doc! { + "$getField": { + "$literal": path_element + } + } + .into(), + ), + } +} + +/// Produces an aggregation expression that evaluates to the value of a given document field. +/// Unlike `column_ref` this expression cannot be used as a match query key - it can only be used +/// as an expression. +pub fn column_expression(column: &ComparisonTarget) -> Bson { + match ColumnRef::from_comparison_target(column) { + ColumnRef::MatchKey(key) => format!("${key}").into(), + ColumnRef::Expression(expr) => expr, + } +} + +#[cfg(test)] +mod tests { + use configuration::MongoScalarType; + use mongodb::bson::doc; + use mongodb_support::BsonScalarType; + use pretty_assertions::assert_eq; + + use crate::mongo_query_plan::{ComparisonTarget, Type}; + + use super::ColumnRef; + + #[test] + fn produces_match_query_key() -> anyhow::Result<()> { + let target = ComparisonTarget::Column { + name: "imdb".into(), + field_path: Some(vec!["rating".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::Double)), + path: Default::default(), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::MatchKey("imdb.rating".into()); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_nested_field_name_with_dots() -> anyhow::Result<()> { + let target = ComparisonTarget::Column { + name: "subtitles".into(), + field_path: Some(vec!["english.us".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": "$subtitles", + "field": { "$literal": "english.us" } , + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_top_level_field_name_with_dots() -> anyhow::Result<()> { + let target = ComparisonTarget::Column { + name: "meta.subtitles".into(), + field_path: Some(vec!["english_us".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": { "$getField": { "$literal": "meta.subtitles" } }, + "field": "english_us", + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_multiple_unsafe_nested_field_names() -> anyhow::Result<()> { + let target = ComparisonTarget::Column { + name: "meta".into(), + field_path: Some(vec!["$unsafe".into(), "$also_unsafe".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": { + "$getField": { + "input": "$meta", + "field": { "$literal": "$unsafe" }, + } + }, + "field": { "$literal": "$also_unsafe" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn traverses_multiple_field_names_before_escaping() -> anyhow::Result<()> { + let target = ComparisonTarget::Column { + name: "valid_key".into(), + field_path: Some(vec!["also_valid".into(), "$not_valid".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": "$valid_key.also_valid", + "field": { "$literal": "$not_valid" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn produces_dot_separated_root_column_reference() -> anyhow::Result<()> { + let target = ComparisonTarget::RootCollectionColumn { + name: "field".into(), + field_path: Some(vec!["prop1".into(), "prop2".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression("$$ROOT.field.prop1.prop2".into()); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_unsafe_field_name_in_root_column_reference() -> anyhow::Result<()> { + let target = ComparisonTarget::RootCollectionColumn { + name: "$field".into(), + field_path: Default::default(), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": "$$ROOT", + "field": { "$literal": "$field" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_unsafe_nested_property_name_in_root_column_reference() -> anyhow::Result<()> { + let target = ComparisonTarget::RootCollectionColumn { + name: "field".into(), + field_path: Some(vec!["$unsafe_name".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": "$$ROOT.field", + "field": { "$literal": "$unsafe_name" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_multiple_layers_of_nested_property_names_in_root_column_reference( + ) -> anyhow::Result<()> { + let target = ComparisonTarget::RootCollectionColumn { + name: "$field".into(), + field_path: Some(vec!["$unsafe_name1".into(), "$unsafe_name2".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": { + "$getField": { + "input": { + "$getField": { + "input": "$$ROOT", + "field": { "$literal": "$field" }, + } + }, + "field": { "$literal": "$unsafe_name1" }, + } + }, + "field": { "$literal": "$unsafe_name2" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn escapes_unsafe_deeply_nested_property_name_in_root_column_reference() -> anyhow::Result<()> { + let target = ComparisonTarget::RootCollectionColumn { + name: "field".into(), + field_path: Some(vec!["prop1".into(), "$unsafe_name".into()]), + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + }; + let actual = ColumnRef::from_comparison_target(&target); + let expected = ColumnRef::Expression( + doc! { + "$getField": { + "input": "$$ROOT.field.prop1", + "field": { "$literal": "$unsafe_name" }, + } + } + .into(), + ); + assert_eq!(actual, expected); + Ok(()) + } +} diff --git a/crates/mongodb-agent-common/src/query/make_selector.rs b/crates/mongodb-agent-common/src/query/make_selector.rs index 0050617b..0aede460 100644 --- a/crates/mongodb-agent-common/src/query/make_selector.rs +++ b/crates/mongodb-agent-common/src/query/make_selector.rs @@ -1,14 +1,14 @@ -use std::{borrow::Cow, collections::BTreeMap, iter::once}; +use std::collections::BTreeMap; use anyhow::anyhow; -use itertools::Either; use mongodb::bson::{self, doc, Document}; use ndc_models::UnaryComparisonOperator; use crate::{ + comparison_function::ComparisonFunction, interface_types::MongoAgentError, mongo_query_plan::{ComparisonTarget, ComparisonValue, ExistsInCollection, Expression, Type}, - mongodb::sanitize::safe_name, + query::column_ref::{column_expression, ColumnRef}, }; use super::serialization::json_to_bson; @@ -57,42 +57,90 @@ pub fn make_selector( }, ExistsInCollection::Unrelated { unrelated_collection, - } => doc! { format!("$$ROOT.{unrelated_collection}.0"): { "$exists": true } }, + } => doc! { + "$expr": { + "$ne": [format!("$$ROOT.{unrelated_collection}.0"), null] + } + }, }), Expression::BinaryComparisonOperator { column, operator, value, - } => { - let comparison_value = match value { - // TODO: MDB-152 To compare to another column we need to wrap the entire expression in - // an `$expr` aggregation operator (assuming the expression is not already in - // an aggregation expression context) - ComparisonValue::Column { .. } => Err(MongoAgentError::NotImplemented( - "comparisons between columns", - )), - ComparisonValue::Scalar { value, value_type } => { - bson_from_scalar_value(value, value_type) - } - ComparisonValue::Variable { - name, - variable_type, - } => variable_to_mongo_expression(variables, name, variable_type).map(Into::into), - }?; - Ok(traverse_relationship_path( - column.relationship_path(), - operator.mongodb_expression(column_ref(column)?, comparison_value), - )) - } + } => make_binary_comparison_selector(variables, column, operator, value), Expression::UnaryComparisonOperator { column, operator } => match operator { - UnaryComparisonOperator::IsNull => Ok(traverse_relationship_path( - column.relationship_path(), - doc! { column_ref(column)?: { "$eq": null } }, - )), + UnaryComparisonOperator::IsNull => { + let match_doc = match ColumnRef::from_comparison_target(column) { + ColumnRef::MatchKey(key) => doc! { + key: { "$eq": null } + }, + ColumnRef::Expression(expr) => doc! { + "$expr": { + "$eq": [expr, null] + } + }, + }; + Ok(traverse_relationship_path( + column.relationship_path(), + match_doc, + )) + } }, } } +fn make_binary_comparison_selector( + variables: Option<&BTreeMap>, + target_column: &ComparisonTarget, + operator: &ComparisonFunction, + value: &ComparisonValue, +) -> Result { + let selector = match value { + ComparisonValue::Column { + column: value_column, + } => { + if !target_column.relationship_path().is_empty() + || !value_column.relationship_path().is_empty() + { + return Err(MongoAgentError::NotImplemented( + "binary comparisons between two fields where either field is in a related collection", + )); + } + doc! { + "$expr": operator.mongodb_aggregation_expression( + column_expression(target_column), + column_expression(value_column) + ) + } + } + ComparisonValue::Scalar { value, value_type } => { + let comparison_value = bson_from_scalar_value(value, value_type)?; + let match_doc = match ColumnRef::from_comparison_target(target_column) { + ColumnRef::MatchKey(key) => operator.mongodb_match_query(key, comparison_value), + ColumnRef::Expression(expr) => { + operator.mongodb_aggregation_expression(expr, comparison_value) + } + }; + traverse_relationship_path(target_column.relationship_path(), match_doc) + } + ComparisonValue::Variable { + name, + variable_type, + } => { + let comparison_value = + variable_to_mongo_expression(variables, name, variable_type).map(Into::into)?; + let match_doc = match ColumnRef::from_comparison_target(target_column) { + ColumnRef::MatchKey(key) => operator.mongodb_match_query(key, comparison_value), + ColumnRef::Expression(expr) => { + operator.mongodb_aggregation_expression(expr, comparison_value) + } + }; + traverse_relationship_path(target_column.relationship_path(), match_doc) + } + }; + Ok(selector) +} + /// For simple cases the target of an expression is a field reference. But if the target is /// a column of a related collection then we're implicitly making an array comparison (because /// related documents always come as an array, even for object relationships), so we have to wrap @@ -121,46 +169,6 @@ fn variable_to_mongo_expression( bson_from_scalar_value(value, value_type) } -/// Given a column target returns a MongoDB expression that resolves to the value of the -/// corresponding field, either in the target collection of a query request, or in the related -/// collection. Resolves nested fields, but does not traverse relationships. -fn column_ref(column: &ComparisonTarget) -> Result> { - let path = match column { - ComparisonTarget::Column { - name, - field_path, - // path, - .. - } => Either::Left( - once(name) - .chain(field_path.iter().flatten()) - .map(AsRef::as_ref), - ), - ComparisonTarget::RootCollectionColumn { - name, field_path, .. - } => Either::Right( - once("$$ROOT") - .chain(once(name.as_ref())) - .chain(field_path.iter().flatten().map(AsRef::as_ref)), - ), - }; - safe_selector(path) -} - -/// Given an iterable of fields to access, ensures that each field name does not include characters -/// that could be interpereted as a MongoDB expression. -fn safe_selector<'a>(path: impl IntoIterator) -> Result> { - let mut safe_elements = path - .into_iter() - .map(safe_name) - .collect::>>>()?; - if safe_elements.len() == 1 { - Ok(safe_elements.pop().unwrap()) - } else { - Ok(Cow::Owned(safe_elements.join("."))) - } -} - #[cfg(test)] mod tests { use configuration::MongoScalarType; @@ -243,4 +251,37 @@ mod tests { assert_eq!(selector, expected); Ok(()) } + + #[test] + fn compares_two_columns() -> anyhow::Result<()> { + let selector = make_selector( + None, + &Expression::BinaryComparisonOperator { + column: ComparisonTarget::Column { + name: "Name".to_owned(), + field_path: None, + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }, + operator: ComparisonFunction::Equal, + value: ComparisonValue::Column { + column: ComparisonTarget::Column { + name: "Title".to_owned(), + field_path: None, + column_type: Type::Scalar(MongoScalarType::Bson(BsonScalarType::String)), + path: Default::default(), + }, + }, + }, + )?; + + let expected = doc! { + "$expr": { + "$eq": ["$Name", "$Title"] + } + }; + + assert_eq!(selector, expected); + Ok(()) + } } diff --git a/crates/mongodb-agent-common/src/query/make_sort.rs b/crates/mongodb-agent-common/src/query/make_sort.rs index 473dc017..f32e7704 100644 --- a/crates/mongodb-agent-common/src/query/make_sort.rs +++ b/crates/mongodb-agent-common/src/query/make_sort.rs @@ -1,4 +1,4 @@ -use itertools::Itertools; +use itertools::Itertools as _; use mongodb::bson::{bson, Document}; use ndc_models::OrderDirection; @@ -49,6 +49,7 @@ pub fn make_sort(order_by: &OrderBy) -> Result { .collect() } +// TODO: MDB-159 Replace use of [safe_name] with [ColumnRef]. fn column_ref_with_path( name: &String, field_path: Option<&[String]>, diff --git a/crates/mongodb-agent-common/src/query/mod.rs b/crates/mongodb-agent-common/src/query/mod.rs index 60c9cad9..2f574656 100644 --- a/crates/mongodb-agent-common/src/query/mod.rs +++ b/crates/mongodb-agent-common/src/query/mod.rs @@ -1,4 +1,5 @@ pub mod arguments; +mod column_ref; mod constants; mod execute_query_request; mod foreach; diff --git a/crates/mongodb-agent-common/src/query/relations.rs b/crates/mongodb-agent-common/src/query/relations.rs index dfbad643..4b321a3c 100644 --- a/crates/mongodb-agent-common/src/query/relations.rs +++ b/crates/mongodb-agent-common/src/query/relations.rs @@ -79,6 +79,7 @@ fn make_lookup_stage( } } +// TODO: MDB-160 Replace uses of [safe_name] with [ColumnRef]. fn single_column_mapping_lookup( from: String, source_selector: &str, diff --git a/crates/ndc-test-helpers/src/lib.rs b/crates/ndc-test-helpers/src/lib.rs index 759f11dd..1859cf6c 100644 --- a/crates/ndc-test-helpers/src/lib.rs +++ b/crates/ndc-test-helpers/src/lib.rs @@ -9,6 +9,7 @@ mod exists_in_collection; mod expressions; mod field; mod object_type; +mod path_element; mod query_response; mod relationships; mod type_helpers; @@ -31,6 +32,7 @@ pub use exists_in_collection::*; pub use expressions::*; pub use field::*; pub use object_type::*; +pub use path_element::*; pub use query_response::*; pub use relationships::*; pub use type_helpers::*; @@ -209,39 +211,3 @@ pub fn empty_expression() -> Expression { expressions: vec![], } } - -#[derive(Clone, Debug)] -pub struct PathElementBuilder { - relationship: String, - arguments: Option>, - predicate: Option>, -} - -pub fn path_element(relationship: &str) -> PathElementBuilder { - PathElementBuilder::new(relationship) -} - -impl PathElementBuilder { - pub fn new(relationship: &str) -> Self { - PathElementBuilder { - relationship: relationship.to_owned(), - arguments: None, - predicate: None, - } - } - - pub fn predicate(mut self, expression: Expression) -> Self { - self.predicate = Some(Box::new(expression)); - self - } -} - -impl From for PathElement { - fn from(value: PathElementBuilder) -> Self { - PathElement { - relationship: value.relationship, - arguments: value.arguments.unwrap_or_default(), - predicate: value.predicate, - } - } -} diff --git a/crates/ndc-test-helpers/src/path_element.rs b/crates/ndc-test-helpers/src/path_element.rs new file mode 100644 index 00000000..d0ee34e6 --- /dev/null +++ b/crates/ndc-test-helpers/src/path_element.rs @@ -0,0 +1,39 @@ +use std::collections::BTreeMap; + +use ndc_models::{Expression, PathElement, RelationshipArgument}; + +#[derive(Clone, Debug)] +pub struct PathElementBuilder { + relationship: String, + arguments: Option>, + predicate: Option>, +} + +pub fn path_element(relationship: &str) -> PathElementBuilder { + PathElementBuilder::new(relationship) +} + +impl PathElementBuilder { + pub fn new(relationship: &str) -> Self { + PathElementBuilder { + relationship: relationship.to_owned(), + arguments: None, + predicate: None, + } + } + + pub fn predicate(mut self, expression: Expression) -> Self { + self.predicate = Some(Box::new(expression)); + self + } +} + +impl From for PathElement { + fn from(value: PathElementBuilder) -> Self { + PathElement { + relationship: value.relationship, + arguments: value.arguments.unwrap_or_default(), + predicate: value.predicate, + } + } +} diff --git a/fixtures/connector/sample_mflix/schema/movies.json b/fixtures/connector/sample_mflix/schema/movies.json index 31237cc7..bb96aee5 100644 --- a/fixtures/connector/sample_mflix/schema/movies.json +++ b/fixtures/connector/sample_mflix/schema/movies.json @@ -167,7 +167,9 @@ } }, "rating": { - "type": "extendedJSON" + "type": { + "scalar": "double" + } }, "votes": { "type": { @@ -282,9 +284,13 @@ } }, "rating": { - "type": "extendedJSON" + "type": { + "nullable": { + "scalar": "double" + } + } } } } } -} \ No newline at end of file +} diff --git a/fixtures/ddn/sample_mflix/dataconnectors/sample_mflix.hml b/fixtures/ddn/sample_mflix/dataconnectors/sample_mflix.hml index 091a6358..762746bb 100644 --- a/fixtures/ddn/sample_mflix/dataconnectors/sample_mflix.hml +++ b/fixtures/ddn/sample_mflix/dataconnectors/sample_mflix.hml @@ -663,7 +663,7 @@ definition: type: nullable underlying_type: type: named - name: ExtendedJSON + name: Double votes: type: type: named @@ -741,7 +741,7 @@ definition: type: nullable underlying_type: type: named - name: ExtendedJSON + name: Double movies_tomatoes_viewer: fields: meter: @@ -757,7 +757,7 @@ definition: type: nullable underlying_type: type: named - name: ExtendedJSON + name: Double sessions: fields: _id: