diff --git a/v3/Cargo.lock b/v3/Cargo.lock index 0018b37db80..e5cc3fe9a04 100644 --- a/v3/Cargo.lock +++ b/v3/Cargo.lock @@ -4130,6 +4130,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" name = "plan" version = "3.0.0" dependencies = [ + "async-recursion", "derive_more", "execute", "graphql-ir", diff --git a/v3/changelog.md b/v3/changelog.md index 73ddc86c853..d06b8153db0 100644 --- a/v3/changelog.md +++ b/v3/changelog.md @@ -4,6 +4,9 @@ ### Added +- Added support for fetching relationships in JSON:API using the `include` + parameter. + ### Fixed - Fixed an error that occurred when filtering by more than one nested field at a diff --git a/v3/crates/engine/src/routes/jsonapi.rs b/v3/crates/engine/src/routes/jsonapi.rs index 880fb6eebc3..b75f1cbf713 100644 --- a/v3/crates/engine/src/routes/jsonapi.rs +++ b/v3/crates/engine/src/routes/jsonapi.rs @@ -143,6 +143,10 @@ async fn handle_rest_request( // we tell the user, for // now default to nothing ), + jsonapi::RequestError::PlanError(plan::PlanError::Relationship(msg)) => ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": msg })), + ), jsonapi::RequestError::ExecuteError(field_error) => ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": field_error.to_string() })), diff --git a/v3/crates/execute/src/plan.rs b/v3/crates/execute/src/plan.rs index 435bd8fa9d8..424236243f9 100644 --- a/v3/crates/execute/src/plan.rs +++ b/v3/crates/execute/src/plan.rs @@ -21,7 +21,7 @@ pub use filter::{ }; pub use mutation::ResolvedMutationExecutionPlan; pub use query::{ResolvedQueryExecutionPlan, ResolvedQueryNode, UnresolvedQueryNode}; -pub use relationships::Relationship; +pub use relationships::{process_model_relationship_definition, Relationship}; use gql::normalized_ast; use gql::schema::NamespacedGetter; diff --git a/v3/crates/jsonapi/src/catalog.rs b/v3/crates/jsonapi/src/catalog.rs index afdbe769572..ca448052f93 100644 --- a/v3/crates/jsonapi/src/catalog.rs +++ b/v3/crates/jsonapi/src/catalog.rs @@ -1,4 +1,4 @@ mod types; -pub use types::{Catalog, Model, ObjectType, State, Type}; +pub use types::{Catalog, Model, ObjectType, RelationshipTarget, State, Type}; mod models; mod object_types; diff --git a/v3/crates/jsonapi/src/catalog/object_types.rs b/v3/crates/jsonapi/src/catalog/object_types.rs index 527dcf46c20..6b63c632d1f 100644 --- a/v3/crates/jsonapi/src/catalog/object_types.rs +++ b/v3/crates/jsonapi/src/catalog/object_types.rs @@ -1,4 +1,4 @@ -use super::types::{ObjectType, ScalarTypeForDataConnector, Type}; +use super::types::{ObjectType, RelationshipTarget, ScalarTypeForDataConnector, Type}; use crate::types::ObjectTypeWarning; use hasura_authn_core::Role; use indexmap::IndexMap; @@ -43,7 +43,27 @@ pub fn build_object_type( type_fields.insert(field_name.clone(), field_type); } - Ok(ObjectType(type_fields)) + // Relationships + let mut type_relationships = IndexMap::new(); + for (_, relationship_field) in &object_type.relationship_fields { + let target = match &relationship_field.target { + metadata_resolve::RelationshipTarget::Model(model) => RelationshipTarget::Model { + object_type: model.target_typename.clone(), + relationship_type: model.relationship_type.clone(), + }, + metadata_resolve::RelationshipTarget::ModelAggregate(model_aggregate) => { + let target_type = model_aggregate.target_typename.clone(); + RelationshipTarget::ModelAggregate(target_type) + } + metadata_resolve::RelationshipTarget::Command(_) => RelationshipTarget::Command, + }; + type_relationships.insert(relationship_field.relationship_name.clone(), target); + } + + Ok(ObjectType { + type_fields, + type_relationships, + }) } // turn an OpenDD type into a type representation diff --git a/v3/crates/jsonapi/src/catalog/types.rs b/v3/crates/jsonapi/src/catalog/types.rs index dd5998922d8..a5430820828 100644 --- a/v3/crates/jsonapi/src/catalog/types.rs +++ b/v3/crates/jsonapi/src/catalog/types.rs @@ -9,6 +9,7 @@ use metadata_resolve::{ use open_dds::{ data_connector::DataConnectorName, models::ModelName, + relationships::{RelationshipName, RelationshipType}, types::{CustomTypeName, FieldName}, }; use serde::{Deserialize, Serialize}; @@ -51,7 +52,20 @@ pub struct State { } #[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct ObjectType(pub IndexMap); +pub struct ObjectType { + pub type_fields: IndexMap, + pub type_relationships: IndexMap, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub enum RelationshipTarget { + Model { + object_type: Qualified, + relationship_type: RelationshipType, + }, + ModelAggregate(Qualified), + Command, // command targets are not supported for now +} impl State { pub fn new(metadata: &metadata_resolve::Metadata, role: &Role) -> (Self, Vec) { diff --git a/v3/crates/jsonapi/src/handler.rs b/v3/crates/jsonapi/src/handler.rs index 7d81cd890fc..3a3b678e901 100644 --- a/v3/crates/jsonapi/src/handler.rs +++ b/v3/crates/jsonapi/src/handler.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use super::parse; use super::process_response; -use super::types::{QueryResult, RequestError}; +use super::types::{QueryResult, RelationshipTree, RequestError}; use crate::catalog::{Catalog, Model, State}; use axum::http::{HeaderMap, Method, Uri}; use hasura_authn_core::Session; @@ -27,6 +27,9 @@ pub async fn handler_internal<'metadata>( .get(&session.role) .ok_or_else(|| RequestError::NotFound)?; + // relationship tree for processing the response + let mut relationship_tree = RelationshipTree::default(); + // route matching/validation match validate_route(state, &uri) { None => Err(RequestError::NotFound), @@ -42,6 +45,7 @@ pub async fn handler_internal<'metadata>( &state.object_types, &http_method, &uri, + &mut relationship_tree, &query_string, ) }, @@ -70,7 +74,7 @@ pub async fn handler_internal<'metadata>( "process_response", "Process response", SpanVisibility::User, - || Ok(process_response::process_result(result)), + || Ok(process_response::process_result(result, &relationship_tree)), ) } } diff --git a/v3/crates/jsonapi/src/parse.rs b/v3/crates/jsonapi/src/parse.rs index e3179c46837..3547bce24da 100644 --- a/v3/crates/jsonapi/src/parse.rs +++ b/v3/crates/jsonapi/src/parse.rs @@ -1,15 +1,21 @@ -use super::types::{ModelInfo, RequestError}; +use super::types::{ModelInfo, RelationshipNode, RelationshipTree, RequestError}; use axum::http::{Method, Uri}; use indexmap::IndexMap; use open_dds::{ identifier, identifier::{Identifier, SubgraphName}, models::ModelName, + query::{ + Alias, ObjectSubSelection, RelationshipSelection, + RelationshipTarget as OpenDdRelationshipTarget, + }, + relationships::{RelationshipName, RelationshipType}, types::{CustomTypeName, FieldName}, }; use serde::{Deserialize, Serialize}; mod filter; -use crate::catalog::{Model, ObjectType}; +mod include; +use crate::catalog::{Model, ObjectType, RelationshipTarget}; use metadata_resolve::Qualified; use std::collections::BTreeMap; @@ -37,6 +43,7 @@ pub fn create_query_ir( object_types: &BTreeMap, ObjectType>, _http_method: &Method, uri: &Uri, + relationship_tree: &mut RelationshipTree, query_string: &jsonapi_library::query::Query, ) -> Result { // get model info from parsing URI @@ -47,31 +54,22 @@ pub fn create_query_ir( relationship: _, } = parse_url(uri).map_err(RequestError::ParseError)?; - let object_type = - get_object_type(object_types, &model.data_type).map_err(RequestError::ParseError)?; + // validate the sparse fields in the query string + validate_sparse_fields(object_types, query_string)?; - validate_sparse_fields(&model.data_type, object_type, query_string)?; + // Parse the include relationships + let include_relationships = query_string + .include + .as_ref() + .map(|include| include::IncludeRelationships::parse(include)); - // create the selection fields; include all fields of the model output type - let mut selection = IndexMap::new(); - for field_name in object_type.0.keys() { - if include_field(query_string, field_name, &model.data_type.name) { - let field_name_ident = Identifier::new(field_name.as_str()) - .map_err(|e| RequestError::BadRequest(e.into()))?; - - let field_name = open_dds::types::FieldName::new(field_name_ident.clone()); - let field_alias = open_dds::query::Alias::new(field_name_ident); - let sub_sel = - open_dds::query::ObjectSubSelection::Field(open_dds::query::ObjectFieldSelection { - target: open_dds::query::ObjectFieldTarget { - arguments: IndexMap::new(), - field_name, - }, - selection: None, - }); - selection.insert(field_alias, sub_sel); - } - } + let field_selection = resolve_field_selection( + object_types, + &model.data_type, + relationship_tree, + query_string, + include_relationships.as_ref(), + )?; // create filters let filter_query = match &query_string.filter { @@ -108,7 +106,7 @@ pub fn create_query_ir( // form the model selection let model_selection = open_dds::query::ModelSelection { - selection, + selection: field_selection, target: open_dds::query::ModelTarget { arguments: IndexMap::new(), filter: filter_query, @@ -129,37 +127,155 @@ pub fn create_query_ir( )) } -// check all fields in sparse fields are accessible, explode if not -// this will disallow relationship or nested fields -fn validate_sparse_fields( +fn resolve_field_selection( + object_types: &BTreeMap, ObjectType>, object_type_name: &Qualified, - object_type: &ObjectType, + relationship_tree: &mut RelationshipTree, + query_string: &jsonapi_library::query::Query, + include_relationships: Option<&include::IncludeRelationships>, +) -> Result, RequestError> { + let object_type = + get_object_type(object_types, object_type_name).map_err(RequestError::ParseError)?; + + // create the selection fields; include all fields of the model output type + let mut selection = IndexMap::new(); + for field_name in object_type.type_fields.keys() { + if include_field(query_string, field_name, &object_type_name.name) { + let field_name_ident = Identifier::new(field_name.as_str()) + .map_err(|e| RequestError::BadRequest(e.into()))?; + + let field_name = open_dds::types::FieldName::new(field_name_ident.clone()); + let field_alias = open_dds::query::Alias::new(field_name_ident); + let sub_sel = + open_dds::query::ObjectSubSelection::Field(open_dds::query::ObjectFieldSelection { + target: open_dds::query::ObjectFieldTarget { + arguments: IndexMap::new(), + field_name, + }, + selection: None, + }); + selection.insert(field_alias, sub_sel); + } + } + // resolve include relationships + let mut relationship_fields = resolve_include_relationships( + object_type, + object_types, + relationship_tree, + query_string, + include_relationships, + )?; + selection.append(&mut relationship_fields); + Ok(selection) +} + +// check all types in sparse fields are accessible, +// for each type check all fields in sparse fields are accessible, explode if not +fn validate_sparse_fields( + object_types: &BTreeMap, ObjectType>, query_string: &jsonapi_library::query::Query, ) -> Result<(), RequestError> { - let type_name_string = object_type_name.name.to_string(); - if let Some(fields) = &query_string.fields { - for (type_name, type_fields) in fields { - if *type_name == type_name_string { - for type_field in type_fields { - let string_fields: Vec<_> = - object_type.0.keys().map(ToString::to_string).collect(); + if let Some(types) = &query_string.fields { + for (type_name, type_fields) in types { + let (_, object_type) = object_types + .iter() + .find(|(object_type_name, _)| object_type_name.name.0.as_str() == type_name) + .ok_or_else(|| { + RequestError::BadRequest(format!("Unknown type in sparse fields: {type_name}")) + })?; - if !string_fields.contains(type_field) { - return Err(RequestError::BadRequest(format!( - "Unknown field in sparse fields: {type_field}" - ))); - } + for type_field in type_fields { + let string_fields: Vec<_> = object_type + .type_fields + .keys() + .map(ToString::to_string) + .collect(); + + if !string_fields.contains(type_field) { + return Err(RequestError::BadRequest(format!( + "Unknown field in sparse fields: {type_field} in {type_name}" + ))); } - } else { - return Err(RequestError::BadRequest(format!( - "Unknown type in sparse fields: {type_name}" - ))); } } } Ok(()) } +fn resolve_include_relationships( + object_type: &ObjectType, + object_types: &BTreeMap, ObjectType>, + relationship_tree: &mut RelationshipTree, + query_string: &jsonapi_library::query::Query, + include_relationships: Option<&include::IncludeRelationships>, +) -> Result, RequestError> { + let mut fields = IndexMap::new(); + if let Some(include_relationships) = include_relationships { + for (relationship, nested_include) in &include_relationships.include { + // Check the presence of the relationship + let Some((relationship_name, target)) = object_type + .type_relationships + .iter() + .find(|&(relationship_name, _)| relationship_name.as_str() == relationship) + else { + return Err(RequestError::BadRequest(format!( + "Relationship {relationship} not found" + ))); + }; + let field_name_ident = Identifier::new(relationship) + .map_err(|e| RequestError::BadRequest(format!("Invalid relationship name: {e}")))?; + let (target_type, relationship_type) = match &target { + RelationshipTarget::Model { + object_type, + relationship_type, + } => (object_type, relationship_type.clone()), + RelationshipTarget::ModelAggregate(model_type) => { + (model_type, RelationshipType::Object) + } + RelationshipTarget::Command => { + return Err(RequestError::BadRequest( + "Command relationship is not supported".to_string(), + )); + } + }; + let mut nested_relationships = RelationshipTree::default(); + let selection = resolve_field_selection( + object_types, + target_type, + &mut nested_relationships, + query_string, + nested_include.as_ref(), + )?; + let relationship_node = RelationshipNode { + object_type: target_type.clone(), + relationship_type: relationship_type.clone(), + nested: nested_relationships, + }; + relationship_tree + .relationships + .insert(relationship.to_string(), relationship_node); + let sub_selection = ObjectSubSelection::Relationship(RelationshipSelection { + target: build_relationship_target(relationship_name.clone()), + selection: Some(selection), + }); + let field_alias = open_dds::query::Alias::new(field_name_ident); + fields.insert(field_alias, sub_selection); + } + } + Ok(fields) +} + +fn build_relationship_target(relationship_name: RelationshipName) -> OpenDdRelationshipTarget { + OpenDdRelationshipTarget { + relationship_name, + arguments: IndexMap::new(), + filter: None, + order_by: Vec::new(), + limit: None, + offset: None, + } +} + // given the sparse fields for this request, should be include a given field in the query? // this does not consider subgraphs at the moment - we match on `CustomTypeName` not // `Qualified`. diff --git a/v3/crates/jsonapi/src/parse/include.rs b/v3/crates/jsonapi/src/parse/include.rs new file mode 100644 index 00000000000..d1ae291b114 --- /dev/null +++ b/v3/crates/jsonapi/src/parse/include.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; + +// Represents a parsed "include" query parameter +#[derive(Debug, Default)] +pub struct IncludeRelationships { + pub include: BTreeMap>, +} + +impl IncludeRelationships { + pub fn parse(include_relationships: &[String]) -> Self { + let mut parser = IncludeRelationships::default(); + for include_str in include_relationships { + let path_segments: Vec<&str> = include_str.trim().split('.').collect(); + parser.add_include_path(&path_segments); + } + parser + } + + fn add_include_path(&mut self, segments: &[&str]) { + match segments { + [] => {} + [first, nested_segments @ ..] => { + let entry = self + .include + .entry((*first).to_string()) + .or_insert_with(|| None); + + if !nested_segments.is_empty() { + let mut nested_include = IncludeRelationships::default(); + nested_include.add_include_path(nested_segments); + match entry { + None => *entry = Some(nested_include), + Some(existing) => { + existing.include.extend(nested_include.include); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_include_various_scenarios() { + // Single relationship + let single_include = vec!["authors".to_string()]; + let single_result = IncludeRelationships::parse(&single_include); + assert!(single_result.include.contains_key("authors")); + assert_eq!(single_result.include.len(), 1); + + // Nested relationship + let nested_include = vec!["authors.books".to_string()]; + let nested_result = IncludeRelationships::parse(&nested_include); + let authors_nested = nested_result + .include + .get("authors") + .unwrap() + .as_ref() + .unwrap(); + assert!(authors_nested.include.contains_key("books")); + + // Multiple relationships + let multi_include = vec![ + "authors".to_string(), + "comments".to_string(), + "authors.books.publisher".to_string(), + ]; + let multi_result = IncludeRelationships::parse(&multi_include); + + // Check multiple top-level relationships + assert!(multi_result.include.contains_key("authors")); + assert!(multi_result.include.contains_key("comments")); + + // Check deep nested relationships + let authors_nested = multi_result + .include + .get("authors") + .unwrap() + .as_ref() + .unwrap(); + let books_nested = authors_nested + .include + .get("books") + .unwrap() + .as_ref() + .unwrap(); + assert!(books_nested.include.contains_key("publisher")); + } + + #[test] + fn test_parse_include_edge_cases() { + // Empty input + let empty_include: Vec = vec![]; + let empty_result = IncludeRelationships::parse(&empty_include); + assert!(empty_result.include.is_empty()); + + // Whitespace handling + let whitespace_include = vec![" authors.books ".to_string()]; + let whitespace_result = IncludeRelationships::parse(&whitespace_include); + assert!(whitespace_result.include.contains_key("authors")); + let authors_nested = whitespace_result + .include + .get("authors") + .unwrap() + .as_ref() + .unwrap(); + assert!(authors_nested.include.contains_key("books")); + } + + #[test] + fn test_parse_include_overlapping_relationships() { + let overlapping_include = vec![ + "authors.books".to_string(), + "authors.books.publisher".to_string(), + "authors.awards".to_string(), + ]; + let result = IncludeRelationships::parse(&overlapping_include); + + // Check base relationship exists + assert!(result.include.contains_key("authors")); + + // Check nested relationships are correctly merged + let authors_nested = result.include.get("authors").unwrap().as_ref().unwrap(); + assert!(authors_nested.include.contains_key("books")); + assert!(authors_nested.include.contains_key("awards")); + + // Check deep nested relationship + let books_nested = authors_nested + .include + .get("books") + .unwrap() + .as_ref() + .unwrap(); + assert!(books_nested.include.contains_key("publisher")); + } +} diff --git a/v3/crates/jsonapi/src/process_response.rs b/v3/crates/jsonapi/src/process_response.rs index f56883835a6..9cd9d171a04 100644 --- a/v3/crates/jsonapi/src/process_response.rs +++ b/v3/crates/jsonapi/src/process_response.rs @@ -1,6 +1,7 @@ +use super::types::{RelationshipNode, RelationshipTree}; use super::QueryResult; use metadata_resolve::Qualified; -use open_dds::types::CustomTypeName; +use open_dds::{relationships::RelationshipType, types::CustomTypeName}; use std::collections::BTreeMap; // a cheap way to get a unique id for each resource. we should probably get this from the @@ -23,43 +24,176 @@ fn to_resource( unique_id: &mut i32, rowset: ndc_models::RowSet, type_name: &Qualified, + relationship_tree: &RelationshipTree, + collect_relationships: &mut Vec, ) -> Vec { let mut resources = vec![]; - if let Some(rows) = rowset.rows { for row in rows { - let mut attributes = BTreeMap::new(); - let id = fresh_id(unique_id); - for (key, ndc_models::RowFieldValue(value)) in row { - attributes.insert(key.to_string(), value); - } - - let rendered_type_name = format!("{}_{}", type_name.subgraph, type_name.name); - - resources.push(jsonapi_library::api::Resource { - _type: rendered_type_name, - id: id.to_string(), - attributes, - links: None, - meta: None, - relationships: None, - }); + let resource_id = fresh_id(unique_id); + let resource = row_to_resource( + unique_id, + relationship_tree, + collect_relationships, + resource_id, + type_name, + row.into_iter().map(|(k, v)| (k.to_string(), v.0)), + ); + resources.push(resource); } } resources } -pub fn process_result(result: QueryResult) -> jsonapi_library::api::DocumentData { +fn render_type_name(type_name: &Qualified) -> String { + format!("{}_{}", type_name.subgraph, type_name.name) +} + +fn row_to_resource( + unique_id: &mut i32, + relationship_tree: &RelationshipTree, + collect_relationships: &mut Vec, + resource_id: i32, + row_type: &Qualified, + row: impl Iterator, +) -> jsonapi_library::model::Resource { + let mut attributes = BTreeMap::new(); + let mut relationships = BTreeMap::new(); + for (key, mut value) in row { + // Check if the key is a relationship + if let Some(relationship_node) = relationship_tree.relationships.get(key.as_str()) { + let RelationshipNode { + nested, + relationship_type, + object_type, + } = relationship_node; + let relationship_type_name = render_type_name(object_type); + let relationship_identifier_data = match value + .get_mut("rows") + .and_then(|rows| rows.as_array_mut()) + { + None => jsonapi_library::model::IdentifierData::None, + Some(relationship_rows) => match relationship_type { + RelationshipType::Object => { + if let Some(object_row_value) = relationship_rows.pop() { + let relationship_id = fresh_id(unique_id); + let resource_identifier = jsonapi_library::model::ResourceIdentifier { + _type: relationship_type_name, + id: relationship_id.to_string(), + }; + // collect this relationship value + collect_relationship_value( + unique_id, + nested, + collect_relationships, + relationship_id, + object_type, + object_row_value, + ); + jsonapi_library::model::IdentifierData::Single(resource_identifier) + } else { + jsonapi_library::model::IdentifierData::None + } + } + RelationshipType::Array => { + let mut resource_identifiers = vec![]; + for object_row_value in relationship_rows.iter_mut() { + let relationship_id = fresh_id(unique_id); + let resource_identifier = jsonapi_library::model::ResourceIdentifier { + _type: relationship_type_name.clone(), + id: relationship_id.to_string(), + }; + // collect this relationship value + collect_relationship_value( + unique_id, + nested, + collect_relationships, + relationship_id, + object_type, + object_row_value.take(), + ); + resource_identifiers.push(resource_identifier); + } + jsonapi_library::model::IdentifierData::Multiple(resource_identifiers) + } + }, + }; + let relationship = jsonapi_library::model::Relationship { + data: Some(relationship_identifier_data), + links: None, + }; + relationships.insert(key.to_string(), relationship); + } else { + attributes.insert(key.to_string(), value); + } + } + + let rendered_type_name = render_type_name(row_type); + jsonapi_library::api::Resource { + _type: rendered_type_name, + id: resource_id.to_string(), + attributes, + links: None, + meta: None, + relationships: if relationships.is_empty() { + None + } else { + Some(relationships) + }, + } +} + +fn collect_relationship_value( + unique_id: &mut i32, + relationship_tree: &RelationshipTree, + collect_relationships: &mut Vec, + resource_id: i32, + row_type: &Qualified, + value: serde_json::Value, +) { + // collect this relationship resource + let object = match value { + serde_json::Value::Object(o) => o, + _ => serde_json::Map::new(), + }; + let relationship_resource = row_to_resource( + unique_id, + relationship_tree, + collect_relationships, + resource_id, + row_type, + object.into_iter(), + ); + collect_relationships.push(relationship_resource); +} + +pub fn process_result( + result: QueryResult, + relationship_tree: &RelationshipTree, +) -> jsonapi_library::api::DocumentData { let mut unique_id = 1; let mut resources = vec![]; + let mut collect_relationships = vec![]; if let Some(first_rowset) = result.rowsets.into_iter().next() { - resources.extend(to_resource(&mut unique_id, first_rowset, &result.type_name)); + resources.extend(to_resource( + &mut unique_id, + first_rowset, + &result.type_name, + relationship_tree, + &mut collect_relationships, + )); } + let included = if collect_relationships.is_empty() { + None + } else { + Some(collect_relationships) + }; + jsonapi_library::api::DocumentData { data: Some(jsonapi_library::api::PrimaryData::Multiple(resources)), - included: None, + included, links: None, meta: None, jsonapi: None, diff --git a/v3/crates/jsonapi/src/schema.rs b/v3/crates/jsonapi/src/schema.rs index 1e766de99cf..8200a576039 100644 --- a/v3/crates/jsonapi/src/schema.rs +++ b/v3/crates/jsonapi/src/schema.rs @@ -49,6 +49,7 @@ fn get_route_for_model(model: &Model, object_type: &ObjectType) -> oas3::spec::O oas3::spec::ObjectOrReference::Object(parameters::page_offset_parameter()), oas3::spec::ObjectOrReference::Object(parameters::fields_parameter(model, object_type)), oas3::spec::ObjectOrReference::Object(parameters::ordering_parameter(model, object_type)), + oas3::spec::ObjectOrReference::Object(parameters::include_parameter(model, object_type)), ]; let mut responses = BTreeMap::new(); diff --git a/v3/crates/jsonapi/src/schema/output.rs b/v3/crates/jsonapi/src/schema/output.rs index 7866955eb1d..699dc417c8d 100644 --- a/v3/crates/jsonapi/src/schema/output.rs +++ b/v3/crates/jsonapi/src/schema/output.rs @@ -52,7 +52,7 @@ fn type_schema(ty: &Type) -> ObjectOrReference { // what we output for each type pub fn object_schema_for_object_type(object_type: &ObjectType) -> ObjectSchema { let mut fields = BTreeMap::new(); - for (name, ty) in &object_type.0 { + for (name, ty) in &object_type.type_fields { fields.insert(name.to_string(), type_schema(ty)); } let required = vec![]; // these are used as output types for the moment so everything is @@ -91,7 +91,7 @@ fn from_type_representation(type_representation: &ndc_models::TypeRepresentation // them stringy fn jsonapi_data_schema(model: &Model, object_type: &ObjectType) -> ObjectSchema { let mut attributes = BTreeMap::new(); - for (field_name, field_type) in &object_type.0 { + for (field_name, field_type) in &object_type.type_fields { attributes.insert(field_name.to_string(), type_schema(field_type)); } diff --git a/v3/crates/jsonapi/src/schema/parameters.rs b/v3/crates/jsonapi/src/schema/parameters.rs index 35bf3f8fff3..9090313035e 100644 --- a/v3/crates/jsonapi/src/schema/parameters.rs +++ b/v3/crates/jsonapi/src/schema/parameters.rs @@ -1,4 +1,4 @@ -use super::shared::{enum_schema, int_schema}; +use super::shared::{array_schema, enum_schema, int_schema, string_schema}; use crate::catalog::{Model, ObjectType}; use std::collections::BTreeMap; use std::string::ToString; @@ -46,14 +46,20 @@ pub fn page_limit_parameter() -> oas3::spec::Parameter { pub fn fields_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec::Parameter { let schema = oas3::spec::ObjectOrReference::Object(oas3::spec::ObjectSchema { items: Some(Box::new(oas3::spec::ObjectOrReference::Object( - enum_schema(object_type.0.keys().map(ToString::to_string).collect()), + enum_schema( + object_type + .type_fields + .keys() + .map(ToString::to_string) + .collect(), + ), ))), ..oas3::spec::ObjectSchema::default() }); let mut example = String::new(); - for (i, field) in object_type.0.keys().enumerate() { - if i > 0 && i < object_type.0.len() { + for (i, field) in object_type.type_fields.keys().enumerate() { + if i > 0 && i < object_type.type_fields.len() { example.push(','); } example.push_str(&field.to_string()); @@ -81,7 +87,7 @@ pub fn ordering_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec // each field can be `thing` (sort ascending by 'thing') or `-thing` (sort descending by // 'thing') let mut sort_keys = Vec::new(); - for type_field in object_type.0.keys() { + for type_field in object_type.type_fields.keys() { sort_keys.push(format!("{type_field}")); sort_keys.push(format!("-{type_field}")); } @@ -121,3 +127,36 @@ pub fn ordering_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec required: None, } } + +pub fn include_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec::Parameter { + let schema = oas3::spec::ObjectOrReference::Object(array_schema( + oas3::spec::ObjectOrReference::Object(string_schema(None)), + )); + let example = object_type + .type_relationships + .keys() + .map(ToString::to_string) + .collect::>() + .join(","); + let description = format!( + "Optional list of relationships from {} to include in the response. \ + Use dot-separated names to include nested relationships.", + model.name.name + ); + oas3::spec::Parameter { + name: "include".into(), + allow_empty_value: None, + allow_reserved: None, + content: None, + deprecated: None, + description: Some(description), + example: Some(example.into()), + explode: None, + examples: BTreeMap::new(), + extensions: BTreeMap::new(), + location: oas3::spec::ParameterIn::Query, + schema: Some(schema), + style: None, + required: None, + } +} diff --git a/v3/crates/jsonapi/src/types.rs b/v3/crates/jsonapi/src/types.rs index 032154bfa6e..137a347b5e5 100644 --- a/v3/crates/jsonapi/src/types.rs +++ b/v3/crates/jsonapi/src/types.rs @@ -1,7 +1,11 @@ use crate::parse; use hasura_authn_core::Role; use metadata_resolve::Qualified; -use open_dds::{identifier::SubgraphName, models::ModelName, types::CustomTypeName}; +use open_dds::{ + identifier::SubgraphName, models::ModelName, relationships::RelationshipType, + types::CustomTypeName, +}; +use std::collections::BTreeMap; use tracing_util::{ErrorVisibility, TraceableError}; #[derive(Debug, Clone)] @@ -63,7 +67,9 @@ impl RequestError { RequestError::InternalError(InternalError::EmptyQuerySet) => { serde_json::json!({"error": "Internal error"}) } - RequestError::PlanError(plan::PlanError::Internal(msg)) => { + RequestError::PlanError( + plan::PlanError::Internal(msg) | plan::PlanError::Relationship(msg), + ) => { serde_json::json!({"error": msg }) } RequestError::PlanError(plan::PlanError::External(_err)) => { @@ -107,3 +113,15 @@ pub struct QueryResult { pub type_name: Qualified, pub rowsets: Vec, } + +/// A tree of relationships, used in processing of relationships in the JSON:API response creation +#[derive(Default)] +pub struct RelationshipTree { + pub relationships: BTreeMap, +} + +pub struct RelationshipNode { + pub object_type: Qualified, + pub relationship_type: RelationshipType, + pub nested: RelationshipTree, +} diff --git a/v3/crates/jsonapi/tests/failing/include/Artist.txt b/v3/crates/jsonapi/tests/failing/include/Artist.txt new file mode 100644 index 00000000000..06973b57f5c --- /dev/null +++ b/v3/crates/jsonapi/tests/failing/include/Artist.txt @@ -0,0 +1 @@ +fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Unknown diff --git a/v3/crates/jsonapi/tests/failing/include/Track.txt b/v3/crates/jsonapi/tests/failing/include/Track.txt new file mode 100644 index 00000000000..478b0bcbecc --- /dev/null +++ b/v3/crates/jsonapi/tests/failing/include/Track.txt @@ -0,0 +1 @@ +fields[Track]=TrackId,Name,Composer&page[limit]=5&include=Album.Unknown diff --git a/v3/crates/jsonapi/tests/jsonapi_golden_tests.rs b/v3/crates/jsonapi/tests/jsonapi_golden_tests.rs index 494ff36046e..961e685d095 100644 --- a/v3/crates/jsonapi/tests/jsonapi_golden_tests.rs +++ b/v3/crates/jsonapi/tests/jsonapi_golden_tests.rs @@ -1,8 +1,9 @@ //! Tests that run JSONAPI to see if it works use hasura_authn_core::{Identity, Role}; +use jsonapi_library::api::{DocumentData, IdentifierData, PrimaryData}; use reqwest::header::HeaderMap; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; @@ -47,6 +48,10 @@ fn test_get_succeeding_requests() { match result { Ok(result) => { + // Assert uniqueness of resources in the response + validate_resource_uniqueness(&result).unwrap(); + // Assert all relationships have corresponding included resources + validate_relationships_in_included(&result).unwrap(); insta::assert_debug_snapshot!( format!("result_for_role_{}", session.role), result @@ -211,3 +216,123 @@ fn get_metadata_resolve_configuration() -> metadata_resolve::configuration::Conf metadata_resolve::configuration::Configuration { unstable_features } } + +/// A Result indicating whether resources are unique, with details of duplicates if not +fn validate_resource_uniqueness( + document_data: &DocumentData, +) -> Result<(), Vec<(String, String, String)>> { + // Collect all resources including primary and included + let mut all_resources = Vec::new(); + + // Add primary data resources + match &document_data.data { + Some(PrimaryData::Single(resource)) => all_resources.push(resource.as_ref()), + Some(PrimaryData::Multiple(resources)) => all_resources.extend(resources), + _ => {} + } + + // Add included resources if present + if let Some(included) = &document_data.included { + all_resources.extend(included); + } + + // Check uniqueness + let mut seen = HashSet::new(); + let duplicates: Vec<_> = all_resources + .iter() + .filter(|r| !seen.insert((r._type.clone(), r.id.clone()))) + .map(|r| { + ( + r._type.clone(), + r.id.clone(), + "Duplicate resource".to_string(), + ) + }) + .collect(); + + if duplicates.is_empty() { + Ok(()) + } else { + Err(duplicates) + } +} + +/// A Result indicating whether all relationships have corresponding included resources +fn validate_relationships_in_included(document_data: &DocumentData) -> Result<(), Vec> { + // Extract all resources to check + let mut all_resources = Vec::new(); + match &document_data.data { + Some(PrimaryData::Single(resource)) => all_resources.push(resource.as_ref()), + Some(PrimaryData::Multiple(resources)) => all_resources.extend(resources), + _ => return Ok(()), + } + + // If no included resources, but relationships exist + if document_data.included.is_none() { + let resources_with_relationships: Vec = all_resources + .iter() + .filter(|r| r.relationships.is_some()) + .map(|r| r._type.to_string()) + .collect(); + + return if resources_with_relationships.is_empty() { + Ok(()) + } else { + Err(resources_with_relationships) + }; + } + + // Get included resources as a lookup set + let included_resources: HashSet<_> = document_data + .included + .as_ref() + .unwrap() + .iter() + .map(|r| (&r._type, &r.id)) + .collect(); + + // Check each resource's relationships + let mut missing_relationships = Vec::new(); + + for resource in &all_resources { + if let Some(relationships) = &resource.relationships { + for (rel_name, relationship) in relationships { + match &relationship.data { + Some(IdentifierData::Single(identifier)) => { + if !included_resources.contains(&(&identifier._type, &identifier.id)) { + missing_relationships.push(format!( + "Resource type: {}, ID: {}, Missing relationship: {} (type: {}, id: {})", + resource._type, + resource.id, + rel_name, + identifier._type, + identifier.id + )); + } + } + Some(IdentifierData::Multiple(identifiers)) => { + for identifier in identifiers { + if !included_resources.contains(&(&identifier._type, &identifier.id)) { + missing_relationships.push(format!( + "Resource type: {}, ID: {}, Missing relationship: {} (type: {}, id: {})", + resource._type, + resource.id, + rel_name, + identifier._type, + identifier.id + )); + } + } + } + _ => {} + } + } + } + } + + if missing_relationships.is_empty() { + Ok(()) + } else { + Err(missing_relationships) + } +} diff --git a/v3/crates/jsonapi/tests/passing/include/Artist.txt b/v3/crates/jsonapi/tests/passing/include/Artist.txt new file mode 100644 index 00000000000..32b00c35dcb --- /dev/null +++ b/v3/crates/jsonapi/tests/passing/include/Artist.txt @@ -0,0 +1 @@ +fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Albums,Albums.Tracks diff --git a/v3/crates/jsonapi/tests/passing/include/Track.txt b/v3/crates/jsonapi/tests/passing/include/Track.txt new file mode 100644 index 00000000000..31f90342009 --- /dev/null +++ b/v3/crates/jsonapi/tests/passing/include/Track.txt @@ -0,0 +1 @@ +fields[Track]=TrackId,Name,Composer&fields[InvoiceLine]=InvoiceId,Quantity,UnitPrice&page[limit]=5&include=Album,InvoiceLines diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Artist.txt.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Artist.txt.snap new file mode 100644 index 00000000000..92a27ba9f8a --- /dev/null +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Artist.txt.snap @@ -0,0 +1,10 @@ +--- +source: crates/jsonapi/tests/jsonapi_golden_tests.rs +expression: result +input_file: crates/jsonapi/tests/failing/include/Artist.txt +--- +Err( + BadRequest( + "Relationship Unknown not found", + ), +) diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Track.txt.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Track.txt.snap new file mode 100644 index 00000000000..9596524927e --- /dev/null +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@include__Track.txt.snap @@ -0,0 +1,10 @@ +--- +source: crates/jsonapi/tests/jsonapi_golden_tests.rs +expression: result +input_file: crates/jsonapi/tests/failing/include/Track.txt +--- +Err( + BadRequest( + "Relationship Unknown not found", + ), +) diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@select_model__Authors.txt.snap similarity index 73% rename from v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin.snap rename to v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@select_model__Authors.txt.snap index 89013bb8b76..099fa10d9ef 100644 --- a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin.snap +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__error_for_role_admin@select_model__Authors.txt.snap @@ -5,6 +5,6 @@ input_file: crates/jsonapi/tests/failing/select_model/Authors.txt --- Err( BadRequest( - "Unknown field in sparse fields: last_name", + "Unknown field in sparse fields: last_name in Author", ), ) diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_admin.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_admin.snap index 5fe85583204..078a14c9ad0 100644 --- a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_admin.snap +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_admin.snap @@ -65,6 +65,18 @@ expression: generated_openapi } }, "example": "AlbumId,-ArtistId" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Album to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Artist,Tracks" } ], "responses": { @@ -175,6 +187,18 @@ expression: generated_openapi } }, "example": "article_id,-title" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Articles to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Author,AuthorFromCommand" } ], "responses": { @@ -282,6 +306,18 @@ expression: generated_openapi } }, "example": "ArtistId,-Name" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Artist to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Albums" } ], "responses": { @@ -387,6 +423,18 @@ expression: generated_openapi } }, "example": "author_id,-first_name" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Authors to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "articles" } ], "responses": { @@ -524,6 +572,18 @@ expression: generated_openapi } }, "example": "Address,-City,Country,-CustomerId" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Customer to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Employee,Invoices" } ], "responses": { @@ -700,6 +760,18 @@ expression: generated_openapi } }, "example": "Address,-BirthDate,Country,-Email" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Employee to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Customers,Employee,Employees" } ], "responses": { @@ -843,6 +915,18 @@ expression: generated_openapi } }, "example": "GenreId,-Name" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Genre to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Tracks" } ], "responses": { @@ -968,6 +1052,18 @@ expression: generated_openapi } }, "example": "BillingAddress,-BillingCity,BillingPostalCode,-BillingState" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Invoice to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Customer,InvoiceLines" } ], "responses": { @@ -1102,6 +1198,18 @@ expression: generated_openapi } }, "example": "InvoiceId,-InvoiceLineId,TrackId,-UnitPrice" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from InvoiceLine to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Invoice,Track" } ], "responses": { @@ -1215,6 +1323,18 @@ expression: generated_openapi } }, "example": "MediaTypeId,-Name" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from MediaType to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Tracks" } ], "responses": { @@ -1319,6 +1439,18 @@ expression: generated_openapi } }, "example": "Name,-PlaylistId" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Playlist to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "PlaylistTracks" } ], "responses": { @@ -1423,6 +1555,18 @@ expression: generated_openapi } }, "example": "PlaylistId,-TrackId" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from PlaylistTrack to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Playlist,Track" } ], "responses": { @@ -1548,6 +1692,18 @@ expression: generated_openapi } }, "example": "AlbumId,-Bytes,GenreId,-MediaTypeId" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Track to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Album,Genre,InvoiceLines,MediaType,PlaylistTracks" } ], "responses": { @@ -1682,6 +1838,18 @@ expression: generated_openapi } }, "example": "id,-name,staff,-departments" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from institutions to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "" } ], "responses": { @@ -1810,6 +1978,18 @@ expression: generated_openapi } }, "example": "auth_name,-auth_srid,srid,-srtext" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from spatial_ref_sys to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "" } ], "responses": { @@ -1941,6 +2121,18 @@ expression: generated_openapi } }, "example": "child_id,-feature_column,layer_id,-level" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from topology_layer to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "topology_topology" } ], "responses": { @@ -2072,6 +2264,18 @@ expression: generated_openapi } }, "example": "hasz,-id,precision,-srid" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from topology_topology to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "topology_layers" } ], "responses": { diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_1.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_1.snap index 93601072364..38b7863ce17 100644 --- a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_1.snap +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_1.snap @@ -62,6 +62,18 @@ expression: generated_openapi } }, "example": "AlbumId,-Title" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Album to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Artist,Tracks" } ], "responses": { @@ -166,6 +178,18 @@ expression: generated_openapi } }, "example": "article_id,-author_id" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Articles to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Author,AuthorFromCommand" } ], "responses": { diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_2.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_2.snap index 364af52a33e..c48352ae23b 100644 --- a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_2.snap +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__generated_openapi_for_role_user_2.snap @@ -62,6 +62,18 @@ expression: generated_openapi } }, "example": "article_id,-title" + }, + { + "name": "include", + "in": "query", + "description": "Optional list of relationships from Articles to include in the response. Use dot-separated names to include nested relationships.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "Author,AuthorFromCommand" } ], "responses": { diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Artist.txt.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Artist.txt.snap new file mode 100644 index 00000000000..8576889bc44 --- /dev/null +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Artist.txt.snap @@ -0,0 +1,387 @@ +--- +source: crates/jsonapi/tests/jsonapi_golden_tests.rs +expression: result +input_file: crates/jsonapi/tests/passing/include/Artist.txt +--- +DocumentData { + data: Some( + Multiple( + [ + Resource { + _type: "default_Artist", + id: "1", + attributes: { + "ArtistId": Number(1), + "Name": String("AC/DC"), + }, + relationships: Some( + { + "Albums": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_Album", + id: "2", + }, + ResourceIdentifier { + _type: "default_Album", + id: "13", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + ], + ), + ), + included: Some( + [ + Resource { + _type: "default_Track", + id: "3", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("For Those About To Rock (We Salute You)"), + "TrackId": Number(1), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "4", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Put The Finger On You"), + "TrackId": Number(6), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "5", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Let's Get It Up"), + "TrackId": Number(7), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "6", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Inject The Venom"), + "TrackId": Number(8), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "7", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Snowballed"), + "TrackId": Number(9), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "8", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Evil Walks"), + "TrackId": Number(10), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "9", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("C.O.D."), + "TrackId": Number(11), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "10", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Breaking The Rules"), + "TrackId": Number(12), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "11", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Night Of The Long Knives"), + "TrackId": Number(13), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "12", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("Spellbound"), + "TrackId": Number(14), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "2", + attributes: { + "AlbumId": Number(1), + "ArtistId": Number(1), + "Title": String("For Those About To Rock We Salute You"), + }, + relationships: Some( + { + "Tracks": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_Track", + id: "3", + }, + ResourceIdentifier { + _type: "default_Track", + id: "4", + }, + ResourceIdentifier { + _type: "default_Track", + id: "5", + }, + ResourceIdentifier { + _type: "default_Track", + id: "6", + }, + ResourceIdentifier { + _type: "default_Track", + id: "7", + }, + ResourceIdentifier { + _type: "default_Track", + id: "8", + }, + ResourceIdentifier { + _type: "default_Track", + id: "9", + }, + ResourceIdentifier { + _type: "default_Track", + id: "10", + }, + ResourceIdentifier { + _type: "default_Track", + id: "11", + }, + ResourceIdentifier { + _type: "default_Track", + id: "12", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "14", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Go Down"), + "TrackId": Number(15), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "15", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Dog Eat Dog"), + "TrackId": Number(16), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "16", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Let There Be Rock"), + "TrackId": Number(17), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "17", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Bad Boy Boogie"), + "TrackId": Number(18), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "18", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Problem Child"), + "TrackId": Number(19), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "19", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Overdose"), + "TrackId": Number(20), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "20", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Hell Ain't A Bad Place To Be"), + "TrackId": Number(21), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "21", + attributes: { + "Composer": String("AC/DC"), + "Name": String("Whole Lotta Rosie"), + "TrackId": Number(22), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "13", + attributes: { + "AlbumId": Number(4), + "ArtistId": Number(1), + "Title": String("Let There Be Rock"), + }, + relationships: Some( + { + "Tracks": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_Track", + id: "14", + }, + ResourceIdentifier { + _type: "default_Track", + id: "15", + }, + ResourceIdentifier { + _type: "default_Track", + id: "16", + }, + ResourceIdentifier { + _type: "default_Track", + id: "17", + }, + ResourceIdentifier { + _type: "default_Track", + id: "18", + }, + ResourceIdentifier { + _type: "default_Track", + id: "19", + }, + ResourceIdentifier { + _type: "default_Track", + id: "20", + }, + ResourceIdentifier { + _type: "default_Track", + id: "21", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + ], + ), + links: None, + meta: None, + jsonapi: None, +} diff --git a/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Track.txt.snap b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Track.txt.snap new file mode 100644 index 00000000000..58c18e97618 --- /dev/null +++ b/v3/crates/jsonapi/tests/snapshots/jsonapi_golden_tests__result_for_role_admin@include__Track.txt.snap @@ -0,0 +1,351 @@ +--- +source: crates/jsonapi/tests/jsonapi_golden_tests.rs +expression: result +input_file: crates/jsonapi/tests/passing/include/Track.txt +--- +DocumentData { + data: Some( + Multiple( + [ + Resource { + _type: "default_Track", + id: "1", + attributes: { + "Composer": String("Angus Young, Malcolm Young, Brian Johnson"), + "Name": String("For Those About To Rock (We Salute You)"), + "TrackId": Number(1), + }, + relationships: Some( + { + "Album": Relationship { + data: Some( + Single( + ResourceIdentifier { + _type: "default_Album", + id: "2", + }, + ), + ), + links: None, + }, + "InvoiceLines": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "3", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "4", + attributes: { + "Composer": Null, + "Name": String("Balls to the Wall"), + "TrackId": Number(2), + }, + relationships: Some( + { + "Album": Relationship { + data: Some( + Single( + ResourceIdentifier { + _type: "default_Album", + id: "5", + }, + ), + ), + links: None, + }, + "InvoiceLines": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "6", + }, + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "7", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "8", + attributes: { + "Composer": String("F. Baltes, S. Kaufman, U. Dirkscneider & W. Hoffman"), + "Name": String("Fast As a Shark"), + "TrackId": Number(3), + }, + relationships: Some( + { + "Album": Relationship { + data: Some( + Single( + ResourceIdentifier { + _type: "default_Album", + id: "9", + }, + ), + ), + links: None, + }, + "InvoiceLines": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "10", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "11", + attributes: { + "Composer": String("F. Baltes, R.A. Smith-Diesel, S. Kaufman, U. Dirkscneider & W. Hoffman"), + "Name": String("Restless and Wild"), + "TrackId": Number(4), + }, + relationships: Some( + { + "Album": Relationship { + data: Some( + Single( + ResourceIdentifier { + _type: "default_Album", + id: "12", + }, + ), + ), + links: None, + }, + "InvoiceLines": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "13", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + Resource { + _type: "default_Track", + id: "14", + attributes: { + "Composer": String("Deaffy & R.A. Smith-Diesel"), + "Name": String("Princess of the Dawn"), + "TrackId": Number(5), + }, + relationships: Some( + { + "Album": Relationship { + data: Some( + Single( + ResourceIdentifier { + _type: "default_Album", + id: "15", + }, + ), + ), + links: None, + }, + "InvoiceLines": Relationship { + data: Some( + Multiple( + [ + ResourceIdentifier { + _type: "default_InvoiceLine", + id: "16", + }, + ], + ), + ), + links: None, + }, + }, + ), + links: None, + meta: None, + }, + ], + ), + ), + included: Some( + [ + Resource { + _type: "default_Album", + id: "2", + attributes: { + "AlbumId": Number(1), + "ArtistId": Number(1), + "Title": String("For Those About To Rock We Salute You"), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "3", + attributes: { + "InvoiceId": Number(108), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "5", + attributes: { + "AlbumId": Number(2), + "ArtistId": Number(2), + "Title": String("Balls to the Wall"), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "6", + attributes: { + "InvoiceId": Number(1), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "7", + attributes: { + "InvoiceId": Number(214), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "9", + attributes: { + "AlbumId": Number(3), + "ArtistId": Number(2), + "Title": String("Restless and Wild"), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "10", + attributes: { + "InvoiceId": Number(319), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "12", + attributes: { + "AlbumId": Number(3), + "ArtistId": Number(2), + "Title": String("Restless and Wild"), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "13", + attributes: { + "InvoiceId": Number(1), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_Album", + id: "15", + attributes: { + "AlbumId": Number(3), + "ArtistId": Number(2), + "Title": String("Restless and Wild"), + }, + relationships: None, + links: None, + meta: None, + }, + Resource { + _type: "default_InvoiceLine", + id: "16", + attributes: { + "InvoiceId": Number(108), + "Quantity": Number(1), + "UnitPrice": Number(0.99), + }, + relationships: None, + links: None, + meta: None, + }, + ], + ), + links: None, + meta: None, + jsonapi: None, +} diff --git a/v3/crates/open-dds/src/query.rs b/v3/crates/open-dds/src/query.rs index ea591f3387e..9327382c01c 100644 --- a/v3/crates/open-dds/src/query.rs +++ b/v3/crates/open-dds/src/query.rs @@ -102,7 +102,7 @@ pub struct RelationshipSelection { #[serde(flatten)] pub target: RelationshipTarget, /// If the relationship output produces an object type or an array of object types, what to select from that/those object(s). - pub selection: Option>, + pub selection: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/v3/crates/plan/Cargo.toml b/v3/crates/plan/Cargo.toml index 3431def868f..9b8768bce3a 100644 --- a/v3/crates/plan/Cargo.toml +++ b/v3/crates/plan/Cargo.toml @@ -15,6 +15,7 @@ metadata-resolve = {path = "../metadata-resolve" } open-dds = { path = "../open-dds" } plan-types = { path = "../plan-types" } +async-recursion = { workspace = true } derive_more = { workspace = true } indexmap = { workspace = true } reqwest = { workspace = true } diff --git a/v3/crates/plan/src/query/field_selection.rs b/v3/crates/plan/src/query/field_selection.rs index 6d68cb11b2d..57bd72bbcae 100644 --- a/v3/crates/plan/src/query/field_selection.rs +++ b/v3/crates/plan/src/query/field_selection.rs @@ -1,15 +1,78 @@ use crate::types::PlanError; +use hasura_authn_core::Session; use indexmap::IndexMap; use std::collections::BTreeMap; +use std::sync::Arc; use execute::plan::{ field::{Field, NestedArray, NestedField}, ResolvedFilterExpression, }; use metadata_resolve::{Metadata, Qualified, QualifiedTypeReference, TypeMapping}; -use open_dds::types::CustomTypeName; +use open_dds::{ + models::ModelName, + permissions::TypeOutputPermission, + query::ObjectFieldSelection, + types::{CustomTypeName, FieldName}, +}; use plan_types::NdcFieldAlias; +pub fn from_field_selection( + field_selection: &ObjectFieldSelection, + session: &Arc, + metadata: &Metadata, + qualified_model_name: &Qualified, + model: &metadata_resolve::ModelWithArgumentPresets, + model_source: &Arc, + model_object_type: &metadata_resolve::ObjectTypeWithRelationships, + field_mappings: &BTreeMap, + type_permissions: &TypeOutputPermission, +) -> Result, PlanError> { + if !type_permissions + .allowed_fields + .contains(&field_selection.target.field_name) + { + return Err(PlanError::Permission(format!( + "role {} does not have permission to select the field {} from type {} of model {}", + session.role, + field_selection.target.field_name, + model.model.data_type, + qualified_model_name + ))); + } + + let field_mapping = field_mappings + .get(&field_selection.target.field_name) + // .map(|field_mapping| field_mapping.column.clone()) + .ok_or_else(|| { + PlanError::Internal(format!( + "couldn't fetch field mapping of field {} in type {} for model {}", + field_selection.target.field_name, model.model.data_type, qualified_model_name + )) + })?; + + let field_type = &model_object_type + .object_type + .fields + .get(&field_selection.target.field_name) + .ok_or_else(|| { + PlanError::Internal(format!( + "could not look up type of field {}", + field_selection.target.field_name + )) + })? + .field_type; + + let fields = ndc_nested_field_selection_for(metadata, field_type, &model_source.type_mappings)?; + + let ndc_field = Field::Column { + column: field_mapping.column.clone(), + fields, + arguments: BTreeMap::new(), + }; + Ok(ndc_field) +} + pub fn ndc_nested_field_selection_for( metadata: &Metadata, column_type: &QualifiedTypeReference, diff --git a/v3/crates/plan/src/query/model.rs b/v3/crates/plan/src/query/model.rs index 6db510e1dcb..d1311db9a6e 100644 --- a/v3/crates/plan/src/query/model.rs +++ b/v3/crates/plan/src/query/model.rs @@ -3,6 +3,7 @@ use super::{field_selection, model_target}; use crate::column::to_resolved_column; use crate::types::PlanError; +use execute::plan::process_model_relationship_definition; use indexmap::IndexMap; use std::collections::BTreeMap; use std::sync::Arc; @@ -15,9 +16,12 @@ use hasura_authn_core::Session; use metadata_resolve::{Metadata, Qualified}; use open_dds::query::{ Aggregate, AggregationFunction, ModelSelection, ModelTarget, ObjectSubSelection, Operand, + RelationshipSelection, }; use open_dds::types::CustomTypeName; -use plan_types::{AggregateFieldSelection, AggregateSelectionSet, NdcFieldAlias}; +use plan_types::{ + AggregateFieldSelection, AggregateSelectionSet, NdcFieldAlias, NdcRelationshipName, +}; pub async fn from_model_aggregate_selection( model_target: &ModelTarget, @@ -105,6 +109,7 @@ pub async fn from_model_aggregate_selection( Ok((model.model.data_type.clone(), query, fields)) } +#[async_recursion::async_recursion] pub async fn from_model_selection( model_selection: &ModelSelection, metadata: &Metadata, @@ -135,16 +140,6 @@ pub async fn from_model_selection( PlanError::Internal(format!("model {qualified_model_name} has no source")) })?; - let metadata_resolve::TypeMapping::Object { field_mappings, .. } = model_source - .type_mappings - .get(&model.model.data_type) - .ok_or_else(|| { - PlanError::Internal(format!( - "couldn't fetch type_mapping of type {} for model {}", - model.model.data_type, qualified_model_name - )) - })?; - let model_object_type = metadata .object_types .get(&model.model.data_type) @@ -155,6 +150,16 @@ pub async fn from_model_selection( )) })?; + let metadata_resolve::TypeMapping::Object { field_mappings, .. } = model_source + .type_mappings + .get(&model.model.data_type) + .ok_or_else(|| { + PlanError::Internal(format!( + "couldn't fetch type_mapping of type {} for model {}", + model.model.data_type, qualified_model_name + )) + })?; + let type_permissions = model_object_type .type_output_permissions .get(&session.role) @@ -165,65 +170,45 @@ pub async fn from_model_selection( )) })?; + let mut relationships = BTreeMap::new(); let mut ndc_fields = IndexMap::new(); - for (field_alias, object_sub_selection) in &model_selection.selection { - let ObjectSubSelection::Field(field_selection) = object_sub_selection else { - return Err(PlanError::Internal( - "only normal field selections are supported in NDCPushDownPlanner.".into(), - )); + let ndc_field = match object_sub_selection { + ObjectSubSelection::Field(field_selection) => field_selection::from_field_selection( + field_selection, + session, + metadata, + &qualified_model_name, + model, + model_source, + model_object_type, + field_mappings, + type_permissions, + )?, + ObjectSubSelection::Relationship(relationship_selection) => { + from_relationship_selection( + relationship_selection, + metadata, + session, + http_context, + request_headers, + model, + model_source, + model_object_type, + &mut relationships, + ) + .await? + } + ObjectSubSelection::RelationshipAggregate(_) => { + return Err(PlanError::Internal( + "only normal field/relationship selections are supported in NDCPushDownPlanner.".into(), + )); + } }; - if !type_permissions - .allowed_fields - .contains(&field_selection.target.field_name) - { - return Err(PlanError::Permission(format!( - "role {} does not have permission to select the field {} from type {} of model {}", - session.role, - field_selection.target.field_name, - model.model.data_type, - qualified_model_name - ))); - } - - let field_mapping = field_mappings - .get(&field_selection.target.field_name) - // .map(|field_mapping| field_mapping.column.clone()) - .ok_or_else(|| { - PlanError::Internal(format!( - "couldn't fetch field mapping of field {} in type {} for model {}", - field_selection.target.field_name, model.model.data_type, qualified_model_name - )) - })?; - - let field_type = &model_object_type - .object_type - .fields - .get(&field_selection.target.field_name) - .ok_or_else(|| { - PlanError::Internal(format!( - "could not look up type of field {}", - field_selection.target.field_name - )) - })? - .field_type; - - let fields = field_selection::ndc_nested_field_selection_for( - metadata, - field_type, - &model_source.type_mappings, - )?; - - let ndc_field = Field::Column { - column: field_mapping.column.clone(), - fields, - arguments: BTreeMap::new(), - }; - ndc_fields.insert(NdcFieldAlias::from(field_alias.as_str()), ndc_field); } - let query = model_target::model_target_to_ndc_query( + let mut query = model_target::model_target_to_ndc_query( model_target, session, http_context, @@ -235,9 +220,143 @@ pub async fn from_model_selection( ) .await?; + // collect relationships accummulated in this scope. + query.collection_relationships.append(&mut relationships); + Ok((model.model.data_type.clone(), query, ndc_fields)) } +pub async fn from_relationship_selection( + relationship_selection: &RelationshipSelection, + metadata: &Metadata, + session: &Arc, + http_context: &Arc, + request_headers: &reqwest::header::HeaderMap, + model: &metadata_resolve::ModelWithArgumentPresets, + model_source: &Arc, + model_object_type: &metadata_resolve::ObjectTypeWithRelationships, + relationships: &mut BTreeMap, +) -> Result, PlanError> { + let RelationshipSelection { target, selection } = relationship_selection; + let (_, relationship_field) = model_object_type + .relationship_fields + .iter() + .find(|(_, relationship_field)| { + relationship_field.relationship_name == target.relationship_name + }) + .ok_or_else(|| { + PlanError::Internal(format!( + "couldn't find the relationship {} in the type {}", + target.relationship_name, model.model.data_type, + )) + })?; + + let metadata_resolve::RelationshipTarget::Model(model_relationship_target) = + &relationship_field.target + else { + return Err(PlanError::Relationship(format!( + "Expecting Model as a relationship target for {}", + target.relationship_name, + ))); + }; + let target_model_name = &model_relationship_target.model_name; + let target_model = metadata.models.get(target_model_name).ok_or_else(|| { + PlanError::Internal(format!("model {target_model_name} not found in metadata")) + })?; + + let target_model_source = + target_model.model.source.as_ref().ok_or_else(|| { + PlanError::Internal(format!("model {target_model_name} has no source")) + })?; + + // Reject remote relationships + if target_model_source.data_connector.name != model_source.data_connector.name { + return Err(PlanError::Relationship(format!( + "Remote relationships are not supported: {}", + &target.relationship_name + ))); + } + + let target_source = metadata_resolve::ModelTargetSource { + model: target_model_source.clone(), + capabilities: relationship_field + .target_capabilities + .as_ref() + .ok_or_else(|| { + PlanError::Relationship(format!( + "Relationship capabilities not found for relationship {} in data connector {}", + &target.relationship_name, &target_model_source.data_connector.name, + )) + })? + .clone(), + }; + let local_model_relationship_info = plan_types::LocalModelRelationshipInfo { + relationship_name: &target.relationship_name, + relationship_type: &model_relationship_target.relationship_type, + source_type: &model.model.data_type, + source_data_connector: &model_source.data_connector, + source_type_mappings: &model_source.type_mappings, + target_source: &target_source, + target_type: &model_relationship_target.target_typename, + mappings: &model_relationship_target.mappings, + }; + + let ndc_relationship_name = + NdcRelationshipName::new(&model.model.data_type, &target.relationship_name); + relationships.insert( + ndc_relationship_name.clone(), + process_model_relationship_definition(&local_model_relationship_info).map_err(|err| { + PlanError::Internal(format!( + "Unable to process relationship {} definition: {}", + &target.relationship_name, err + )) + })?, + ); + + let relationship_model_target = ModelTarget { + subgraph: target_model_name.subgraph.clone(), + model_name: target_model_name.name.clone(), + arguments: target.arguments.clone(), + filter: target.filter.clone(), + order_by: target.order_by.clone(), + limit: target.limit, + offset: target.offset, + }; + + let relationship_target_model_selection = ModelSelection { + target: relationship_model_target, + selection: selection.as_ref().map_or_else(IndexMap::new, Clone::clone), + }; + + let (_, ndc_query, relationship_fields) = from_model_selection( + &relationship_target_model_selection, + metadata, + session, + http_context, + request_headers, + ) + .await?; + let QueryExecutionPlan { + remote_predicates: _, + query_node, + collection: _, + arguments, + mut collection_relationships, + variables: _, + data_connector: _, + } = ndc_query_to_query_execution_plan(&ndc_query, &relationship_fields, &IndexMap::new()); + + // Collect relationships from the generated query above + relationships.append(&mut collection_relationships); + + let ndc_field = Field::Relationship { + relationship: ndc_relationship_name, + arguments, + query_node: Box::new(query_node), + }; + Ok(ndc_field) +} + // take NDCQuery and fields and make a sweet execution plan pub fn ndc_query_to_query_execution_plan( query: &NDCQuery, diff --git a/v3/crates/plan/src/types.rs b/v3/crates/plan/src/types.rs index 590fa70d211..a6c0cf3c3f4 100644 --- a/v3/crates/plan/src/types.rs +++ b/v3/crates/plan/src/types.rs @@ -2,5 +2,6 @@ pub enum PlanError { Internal(String), // equivalent to DataFusionError::Internal Permission(String), // equivalent to DataFusionError::Plan + Relationship(String), External(Box), //equivalent to DataFusionError::External } diff --git a/v3/crates/sql/src/execute/planner/common.rs b/v3/crates/sql/src/execute/planner/common.rs index eeaaf34e78a..3a833f98f12 100644 --- a/v3/crates/sql/src/execute/planner/common.rs +++ b/v3/crates/sql/src/execute/planner/common.rs @@ -16,7 +16,7 @@ impl PhysicalPlanOptions { pub fn from_plan_error(plan: PlanError) -> DataFusionError { match plan { PlanError::Internal(msg) => DataFusionError::Internal(msg), - PlanError::Permission(msg) => DataFusionError::Plan(msg), + PlanError::Permission(msg) | PlanError::Relationship(msg) => DataFusionError::Plan(msg), PlanError::External(error) => DataFusionError::External(error), } }