mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
jsonapi: Support fetching relationships (#1360)
<!-- The PR description should answer 2 important questions: --> ### What ```http GET /v1/rest/default/Articles/?page[limit]=2&fields[Article]=title,author_id&fields[Author]=first_name&include=Author,Author.articles HTTP/1.1 ``` <details> <summary>Response</summary> ```json { "data": [ { "type": "default_Article", "id": "1", "attributes": { "author_id": 1, "title": "The Next 700 Programming Languages" }, "relationships": { "Author": { "data": { "type": "default_Author", "id": "2" } } } }, { "type": "default_Article", "id": "5", "attributes": { "author_id": 2, "title": "Why Functional Programming Matters" }, "relationships": { "Author": { "data": { "type": "default_Author", "id": "6" } } } } ], "included": [ { "type": "default_Article", "id": "3", "attributes": { "author_id": 1, "title": "The Next 700 Programming Languages" } }, { "type": "default_Article", "id": "4", "attributes": { "author_id": 1, "title": "The Mechanical Evaluation of Expressions" } }, { "type": "default_Author", "id": "2", "attributes": { "first_name": "Peter" }, "relationships": { "articles": { "data": [ { "type": "default_Article", "id": "3" }, { "type": "default_Article", "id": "4" } ] } } }, { "type": "default_Article", "id": "7", "attributes": { "author_id": 2, "title": "Why Functional Programming Matters" } }, { "type": "default_Article", "id": "8", "attributes": { "author_id": 2, "title": "The Design And Implementation Of Programming Languages" } }, { "type": "default_Article", "id": "9", "attributes": { "author_id": 2, "title": "Generalizing monads to arrows" } }, { "type": "default_Author", "id": "6", "attributes": { "first_name": "John" }, "relationships": { "articles": { "data": [ { "type": "default_Article", "id": "7" }, { "type": "default_Article", "id": "8" }, { "type": "default_Article", "id": "9" } ] } } } ] } ``` </details> <!-- What is this PR trying to accomplish (and why, if it's not obvious)? --> <!-- Consider: do we need to add a changelog entry? --> <!-- Does this PR introduce new validation that might break old builds? --> <!-- Consider: do we need to put new checks behind a flag? --> ### How - Return the `include` parameter in the openapi schema (`/v1/rest/__schema`). - Resolve relationship fields in the OpenDD query request. Only local relationships allowed for now. - A parser to parse the `include` parameter with nested relationships and build OpenDD query AST for relationship selection. - Add tests <!-- How is it trying to accomplish it (what are the implementation steps)? --> V3_GIT_ORIGIN_REV_ID: 516ca9f117f242892146d6026b35266cbaab591d
This commit is contained in:
parent
516a4a377f
commit
0e1972e7c2
1
v3/Cargo.lock
generated
1
v3/Cargo.lock
generated
@ -4130,6 +4130,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
|
||||
name = "plan"
|
||||
version = "3.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"derive_more",
|
||||
"execute",
|
||||
"graphql-ir",
|
||||
|
@ -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
|
||||
|
@ -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() })),
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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<FieldName, Type>);
|
||||
pub struct ObjectType {
|
||||
pub type_fields: IndexMap<FieldName, Type>,
|
||||
pub type_relationships: IndexMap<RelationshipName, RelationshipTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum RelationshipTarget {
|
||||
Model {
|
||||
object_type: Qualified<CustomTypeName>,
|
||||
relationship_type: RelationshipType,
|
||||
},
|
||||
ModelAggregate(Qualified<CustomTypeName>),
|
||||
Command, // command targets are not supported for now
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(metadata: &metadata_resolve::Metadata, role: &Role) -> (Self, Vec<RoleWarning>) {
|
||||
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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<Qualified<CustomTypeName>, ObjectType>,
|
||||
_http_method: &Method,
|
||||
uri: &Uri,
|
||||
relationship_tree: &mut RelationshipTree,
|
||||
query_string: &jsonapi_library::query::Query,
|
||||
) -> Result<open_dds::query::QueryRequest, RequestError> {
|
||||
// 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<Qualified<CustomTypeName>, ObjectType>,
|
||||
object_type_name: &Qualified<CustomTypeName>,
|
||||
object_type: &ObjectType,
|
||||
relationship_tree: &mut RelationshipTree,
|
||||
query_string: &jsonapi_library::query::Query,
|
||||
include_relationships: Option<&include::IncludeRelationships>,
|
||||
) -> Result<IndexMap<Alias, ObjectSubSelection>, 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<Qualified<CustomTypeName>, 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<Qualified<CustomTypeName>, ObjectType>,
|
||||
relationship_tree: &mut RelationshipTree,
|
||||
query_string: &jsonapi_library::query::Query,
|
||||
include_relationships: Option<&include::IncludeRelationships>,
|
||||
) -> Result<IndexMap<Alias, ObjectSubSelection>, 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<CustomTypeName>`.
|
||||
|
140
v3/crates/jsonapi/src/parse/include.rs
Normal file
140
v3/crates/jsonapi/src/parse/include.rs
Normal file
@ -0,0 +1,140 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Represents a parsed "include" query parameter
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IncludeRelationships {
|
||||
pub include: BTreeMap<String, Option<IncludeRelationships>>,
|
||||
}
|
||||
|
||||
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<String> = 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"));
|
||||
}
|
||||
}
|
@ -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<CustomTypeName>,
|
||||
relationship_tree: &RelationshipTree,
|
||||
collect_relationships: &mut Vec<jsonapi_library::model::Resource>,
|
||||
) -> Vec<jsonapi_library::model::Resource> {
|
||||
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<CustomTypeName>) -> String {
|
||||
format!("{}_{}", type_name.subgraph, type_name.name)
|
||||
}
|
||||
|
||||
fn row_to_resource(
|
||||
unique_id: &mut i32,
|
||||
relationship_tree: &RelationshipTree,
|
||||
collect_relationships: &mut Vec<jsonapi_library::model::Resource>,
|
||||
resource_id: i32,
|
||||
row_type: &Qualified<CustomTypeName>,
|
||||
row: impl Iterator<Item = (String, serde_json::Value)>,
|
||||
) -> 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<jsonapi_library::model::Resource>,
|
||||
resource_id: i32,
|
||||
row_type: &Qualified<CustomTypeName>,
|
||||
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,
|
||||
|
@ -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();
|
||||
|
@ -52,7 +52,7 @@ fn type_schema(ty: &Type) -> ObjectOrReference<ObjectSchema> {
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
@ -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::<Vec<String>>()
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
@ -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<CustomTypeName>,
|
||||
pub rowsets: Vec<ndc_models::RowSet>,
|
||||
}
|
||||
|
||||
/// A tree of relationships, used in processing of relationships in the JSON:API response creation
|
||||
#[derive(Default)]
|
||||
pub struct RelationshipTree {
|
||||
pub relationships: BTreeMap<String, RelationshipNode>,
|
||||
}
|
||||
|
||||
pub struct RelationshipNode {
|
||||
pub object_type: Qualified<CustomTypeName>,
|
||||
pub relationship_type: RelationshipType,
|
||||
pub nested: RelationshipTree,
|
||||
}
|
||||
|
1
v3/crates/jsonapi/tests/failing/include/Artist.txt
Normal file
1
v3/crates/jsonapi/tests/failing/include/Artist.txt
Normal file
@ -0,0 +1 @@
|
||||
fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Unknown
|
1
v3/crates/jsonapi/tests/failing/include/Track.txt
Normal file
1
v3/crates/jsonapi/tests/failing/include/Track.txt
Normal file
@ -0,0 +1 @@
|
||||
fields[Track]=TrackId,Name,Composer&page[limit]=5&include=Album.Unknown
|
@ -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<String>> {
|
||||
// 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<String> = 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)
|
||||
}
|
||||
}
|
||||
|
1
v3/crates/jsonapi/tests/passing/include/Artist.txt
Normal file
1
v3/crates/jsonapi/tests/passing/include/Artist.txt
Normal file
@ -0,0 +1 @@
|
||||
fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Albums,Albums.Tracks
|
1
v3/crates/jsonapi/tests/passing/include/Track.txt
Normal file
1
v3/crates/jsonapi/tests/passing/include/Track.txt
Normal file
@ -0,0 +1 @@
|
||||
fields[Track]=TrackId,Name,Composer&fields[InvoiceLine]=InvoiceId,Quantity,UnitPrice&page[limit]=5&include=Album,InvoiceLines
|
@ -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",
|
||||
),
|
||||
)
|
@ -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",
|
||||
),
|
||||
)
|
@ -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",
|
||||
),
|
||||
)
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -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,
|
||||
}
|
@ -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,
|
||||
}
|
@ -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<IndexMap<String, ObjectSubSelection>>,
|
||||
pub selection: Option<IndexMap<Alias, ObjectSubSelection>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
|
@ -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 }
|
||||
|
@ -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<Session>,
|
||||
metadata: &Metadata,
|
||||
qualified_model_name: &Qualified<ModelName>,
|
||||
model: &metadata_resolve::ModelWithArgumentPresets,
|
||||
model_source: &Arc<metadata_resolve::ModelSource>,
|
||||
model_object_type: &metadata_resolve::ObjectTypeWithRelationships,
|
||||
field_mappings: &BTreeMap<FieldName, metadata_resolve::FieldMapping>,
|
||||
type_permissions: &TypeOutputPermission,
|
||||
) -> Result<Field<ResolvedFilterExpression>, 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,
|
||||
|
@ -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<Session>,
|
||||
http_context: &Arc<execute::HttpContext>,
|
||||
request_headers: &reqwest::header::HeaderMap,
|
||||
model: &metadata_resolve::ModelWithArgumentPresets,
|
||||
model_source: &Arc<metadata_resolve::ModelSource>,
|
||||
model_object_type: &metadata_resolve::ObjectTypeWithRelationships,
|
||||
relationships: &mut BTreeMap<plan_types::NdcRelationshipName, execute::plan::Relationship>,
|
||||
) -> Result<Field<ResolvedFilterExpression>, 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,
|
||||
|
@ -2,5 +2,6 @@
|
||||
pub enum PlanError {
|
||||
Internal(String), // equivalent to DataFusionError::Internal
|
||||
Permission(String), // equivalent to DataFusionError::Plan
|
||||
Relationship(String),
|
||||
External(Box<dyn std::error::Error + Send + Sync>), //equivalent to DataFusionError::External
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user