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:
Rakesh Emmadi 2024-11-21 20:35:01 +05:30 committed by hasura-bot
parent 516a4a377f
commit 0e1972e7c2
34 changed files with 1957 additions and 152 deletions

1
v3/Cargo.lock generated
View File

@ -4130,6 +4130,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
name = "plan"
version = "3.0.0"
dependencies = [
"async-recursion",
"derive_more",
"execute",
"graphql-ir",

View File

@ -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

View File

@ -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() })),

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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>) {

View File

@ -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)),
)
}
}

View File

@ -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 {
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}"))
})?;
for type_field in type_fields {
let string_fields: Vec<_> =
object_type.0.keys().map(ToString::to_string).collect();
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}"
"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>`.

View 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"));
}
}

View File

@ -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,

View File

@ -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();

View File

@ -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));
}

View File

@ -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,
}
}

View File

@ -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,
}

View File

@ -0,0 +1 @@
fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Unknown

View File

@ -0,0 +1 @@
fields[Track]=TrackId,Name,Composer&page[limit]=5&include=Album.Unknown

View File

@ -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)
}
}

View File

@ -0,0 +1 @@
fields[Artist]=ArtistId,Name&fields[Track]=TrackId,Name,Composer&page[limit]=1&include=Albums,Albums.Tracks

View File

@ -0,0 +1 @@
fields[Track]=TrackId,Name,Composer&fields[InvoiceLine]=InvoiceId,Quantity,UnitPrice&page[limit]=5&include=Album,InvoiceLines

View File

@ -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",
),
)

View File

@ -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",
),
)

View File

@ -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",
),
)

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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)]

View File

@ -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 }

View File

@ -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,

View File

@ -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(),
));
};
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(
let ndc_field = match object_sub_selection {
ObjectSubSelection::Field(field_selection) => field_selection::from_field_selection(
field_selection,
session,
metadata,
field_type,
&model_source.type_mappings,
)?;
let ndc_field = Field::Column {
column: field_mapping.column.clone(),
fields,
arguments: BTreeMap::new(),
&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(),
));
}
};
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,

View File

@ -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
}

View File

@ -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),
}
}