use std::collections::HashMap;
use super::{
query::QueryRunner,
query_projection::field_type_to_where_type,
query_regular::{DirectReadCost, GatedFieldHandling, ResolvedDirectRead},
};
use crate::{
backend::{
COMPOSED_DOCUMENT_KEY, COMPOSED_EMBEDS_KEY, ComposedEmbed, ComposedLevel, EmbedShape,
EmbedSource, JsonbValue, LevelKeys, ScalarFieldType,
},
error::{FraiseQLError, Result},
graphql::DirectReadProjection,
runtime::{CountSelection, EmbedSelection, ResultProjector, matcher::QueryMatch},
schema::{CompiledSchema, QueryDefinition, Relationship, TypeDefinition},
security::SecurityContext,
};
pub(super) struct ComposedPlan {
query_match: QueryMatch,
resolved: ResolvedDirectRead,
embeds: Vec<PlannedEmbed>,
}
struct PlannedEmbed {
output_key: String,
relationship: Relationship,
target: EmbedTarget,
}
enum EmbedTarget {
Rows(Box<ComposedPlan>),
Count(Box<ResolvedDirectRead>),
Unreadable,
}
impl QueryRunner {
pub(in super::super) async fn execute_query_composed(
&self,
query_match: &QueryMatch,
embeds: &[EmbedSelection],
counts: &[CountSelection],
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
request_budget: Option<&crate::security::RequestBudget>,
) -> Result<serde_json::Value> {
let expanded = super::query_nested::expand_leaf_objects(&self.ctx.schema, query_match)?;
let query_match = &*expanded;
let plan = self.resolve_composed_read(
query_match,
embeds,
counts,
variables,
security_context,
request_budget,
)?;
plan.refuse_gated_leaf_objects(self, security_context)?;
let read = plan.lower(&self.ctx.schema);
let session_pairs = plan.resolved.session_pairs();
if !self.ctx.adapter.supports_composed_reads() {
return Err(crate::backend::composed_read_unsupported(&read.view));
}
let rows = self
.ctx
.adapter
.execute_composed_with_session(
&read,
&session_pairs,
query_match.query_def.read_routing,
)
.await?;
let own_budget = if request_budget.is_none() {
plan.resolved.budget()
} else {
None
};
if let Some(budget) = request_budget
.and_then(crate::security::RequestBudget::bytes)
.or(own_budget.as_ref())
{
budget.charge_jsonb_rows(&rows)?;
}
let composed: Vec<serde_json::Value> = rows.iter().map(|r| r.data.clone()).collect();
let projected =
self.project_composed(&plan, &composed, query_match.query_def.returns_list)?;
Ok(ResultProjector::wrap_in_data_envelope(projected, query_match.response_key()))
}
fn resolve_composed_read(
&self,
query_match: &QueryMatch,
embeds: &[EmbedSelection],
counts: &[CountSelection],
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
request_budget: Option<&crate::security::RequestBudget>,
) -> Result<ComposedPlan> {
let resolved = self.resolve_direct_read(
query_match,
variables,
security_context,
GatedFieldHandling::RefuseAsUnsupported,
DirectReadCost::Composed,
None,
)?;
let embeds = self.plan_embeds(
&query_match.query_def.return_type,
"",
embeds,
counts,
variables,
security_context,
)?;
let plan = ComposedPlan {
query_match: query_match.clone(),
resolved,
embeds,
};
self.charge_direct_read_cost(
&query_match.query_def.name,
&plan.projection(),
request_budget,
)?;
Ok(plan)
}
fn plan_embeds(
&self,
parent_type: &str,
path: &str,
embeds: &[EmbedSelection],
counts: &[CountSelection],
variables: Option<&serde_json::Value>,
security_context: Option<&SecurityContext>,
) -> Result<Vec<PlannedEmbed>> {
let schema = &self.ctx.schema;
let mut planned = Vec::with_capacity(embeds.len() + counts.len());
let nesting = |relationship: &str| {
let path = if path.is_empty() {
relationship.to_string()
} else {
format!("{path}.{relationship}")
};
crate::security::AuthzNesting::new(parent_type, path)
};
for selection in embeds {
let relationship = declared_relationship(schema, parent_type, &selection.relationship)?;
let nesting = nesting(&selection.relationship);
let target = match list_query_for_type(schema, &relationship.target_type) {
None => EmbedTarget::Unreadable,
Some(target_query) => {
let query_match = level_query_match(
schema,
target_query,
selection.fields.clone(),
selection.filter.as_ref(),
selection.limit,
)?;
let resolved = self.resolve_direct_read(
&query_match,
variables,
security_context,
GatedFieldHandling::RefuseAsUnsupported,
DirectReadCost::Composed,
Some(&nesting),
)?;
let embeds = self.plan_embeds(
&relationship.target_type,
&nesting.path,
&selection.embeds,
&selection.counts,
variables,
security_context,
)?;
EmbedTarget::Rows(Box::new(ComposedPlan {
query_match,
resolved,
embeds,
}))
},
};
planned.push(PlannedEmbed {
output_key: selection.output_key.clone(),
relationship,
target,
});
}
for selection in counts {
let relationship = declared_relationship(schema, parent_type, &selection.relationship)?;
let nesting = nesting(&selection.relationship);
let target = match list_query_for_type(schema, &relationship.target_type) {
None => EmbedTarget::Unreadable,
Some(target_query) => {
let query_match = level_query_match(
schema,
target_query,
Vec::new(),
selection.filter.as_ref(),
None,
)?;
EmbedTarget::Count(Box::new(self.resolve_direct_read(
&query_match,
variables,
security_context,
GatedFieldHandling::RefuseAsUnsupported,
DirectReadCost::Composed,
Some(&nesting),
)?))
},
};
planned.push(PlannedEmbed {
output_key: selection.output_key.clone(),
relationship,
target,
});
}
Ok(planned)
}
fn project_composed(
&self,
plan: &ComposedPlan,
rows: &[serde_json::Value],
returns_list: bool,
) -> Result<serde_json::Value> {
let documents: Vec<JsonbValue> = rows
.iter()
.map(|row| {
JsonbValue::new(
row.get(COMPOSED_DOCUMENT_KEY).cloned().unwrap_or(serde_json::Value::Null),
)
})
.collect();
let mut projected = self.project_direct_rows(
&plan.query_match,
&plan.resolved.selection,
&documents,
returns_list,
)?;
match &mut projected {
serde_json::Value::Array(items) => {
for (item, row) in items.iter_mut().zip(rows) {
self.attach_embeds(plan, item, row.get(COMPOSED_EMBEDS_KEY))?;
}
},
serde_json::Value::Object(_) => {
if let Some(row) = rows.first() {
self.attach_embeds(plan, &mut projected, row.get(COMPOSED_EMBEDS_KEY))?;
}
},
_ => {},
}
Ok(projected)
}
fn attach_embeds(
&self,
plan: &ComposedPlan,
item: &mut serde_json::Value,
embedded: Option<&serde_json::Value>,
) -> Result<()> {
let Some(object) = item.as_object_mut() else {
return Ok(());
};
for embed in &plan.embeds {
let raw = embedded.and_then(|e| e.get(&embed.output_key));
let to_one = embed.relationship.cardinality.is_to_one();
let value = match &embed.target {
EmbedTarget::Unreadable if to_one => serde_json::Value::Null,
EmbedTarget::Unreadable => serde_json::json!([]),
EmbedTarget::Count(_) => raw.cloned().unwrap_or_else(|| serde_json::json!(0)),
EmbedTarget::Rows(child) if to_one => match raw {
Some(row @ serde_json::Value::Object(_)) => {
self.project_composed(child, std::slice::from_ref(row), false)?
},
_ => serde_json::Value::Null,
},
EmbedTarget::Rows(child) => {
let rows =
raw.and_then(serde_json::Value::as_array).map_or(&[][..], Vec::as_slice);
self.project_composed(child, rows, true)?
},
};
object.insert(embed.output_key.clone(), value);
}
Ok(())
}
}
impl ComposedPlan {
fn refuse_gated_leaf_objects(
&self,
runner: &QueryRunner,
security_context: Option<&SecurityContext>,
) -> Result<()> {
let fields = self
.query_match
.selections
.first()
.map_or(&[][..], |r| r.nested_fields.as_slice());
if !runner
.plan_nested_reads(
&self.query_match.query_def.return_type,
fields,
security_context,
&self.resolved.selection,
)?
.is_empty()
{
return Err(FraiseQLError::Unsupported {
message: format!(
"'{}' selects a nested object whose type scopes its rows alongside an \
embedded relationship, which this read cannot compose; select it without \
the embed, or embed it through its relationship",
self.query_match.query_def.return_type
),
});
}
for embed in &self.embeds {
if let EmbedTarget::Rows(child) = &embed.target {
child.refuse_gated_leaf_objects(runner, security_context)?;
}
}
Ok(())
}
fn projection(&self) -> DirectReadProjection {
DirectReadProjection {
leaf_fields: self.resolved.leaf_fields,
limit: self.resolved.limit,
nested: self
.embeds
.iter()
.filter_map(|embed| match &embed.target {
EmbedTarget::Rows(child) => Some(child.projection()),
EmbedTarget::Count(_) => Some(DirectReadProjection::flat(0, None)),
EmbedTarget::Unreadable => None,
})
.collect(),
}
}
fn lower(&self, schema: &CompiledSchema) -> ComposedLevel {
self.lower_level(schema, LevelKeys::Whole)
}
fn lower_level(&self, schema: &CompiledSchema, keys: LevelKeys) -> ComposedLevel {
let parent_type = &self.query_match.query_def.return_type;
ComposedLevel {
view: self.resolved.sql_source.clone(),
projection: self.resolved.projection.as_ref().map(|h| h.projection_template.clone()),
where_clause: self.resolved.composed_where.clone(),
order_by: self.resolved.order_by.clone(),
limit: self.resolved.limit,
offset: self.resolved.offset,
keyset: None,
keys,
embeds: self
.embeds
.iter()
.filter_map(|embed| embed.lower(schema, parent_type))
.collect(),
}
}
}
impl PlannedEmbed {
fn lower(&self, schema: &CompiledSchema, parent_type: &str) -> Option<ComposedEmbed> {
let rel = &self.relationship;
let (shape, level) = match &self.target {
EmbedTarget::Unreadable => return None,
EmbedTarget::Rows(child) => {
let shape = if rel.cardinality.is_to_one() {
EmbedShape::One
} else {
EmbedShape::Many
};
(shape, child.lower_level(schema, level_keys(&child.resolved)))
},
EmbedTarget::Count(resolved) => (
EmbedShape::Count,
ComposedLevel {
view: resolved.sql_source.clone(),
projection: None,
where_clause: resolved.composed_where.clone(),
order_by: None,
limit: None,
offset: None,
keyset: None,
keys: LevelKeys::Only {
kept: Vec::new(),
masked: Vec::new(),
},
embeds: Vec::new(),
},
),
};
Some(ComposedEmbed {
output_key: self.output_key.clone(),
shape,
source: correlated_source(schema, rel, parent_type),
level,
})
}
}
pub(super) fn correlated_source(
schema: &CompiledSchema,
rel: &Relationship,
parent_type: &str,
) -> EmbedSource {
let (target_key, parent_key, key_type) = correlation_keys(schema, rel, parent_type);
EmbedSource::Correlated {
target_key,
parent_key,
key_type,
}
}
pub(super) fn correlation_keys(
schema: &CompiledSchema,
rel: &Relationship,
parent_type: &str,
) -> (Vec<String>, Vec<String>, ScalarFieldType) {
let target_field = schema
.find_type(&rel.target_type)
.and_then(|t| t.field_for_column(rel.target_join_column()));
let key_type = target_field
.map_or(ScalarFieldType::Text, |field| field_type_to_where_type(&field.field_type));
(
vec![stored_key(
schema,
&rel.target_type,
rel.target_join_column(),
)],
vec![stored_key(schema, parent_type, rel.parent_join_column())],
key_type,
)
}
fn level_keys(resolved: &ResolvedDirectRead) -> LevelKeys {
let access = &resolved.access;
let mut kept = Vec::new();
let mut masked = Vec::new();
for field in &access.projected {
let into = if access.masked.contains(field) {
&mut masked
} else {
&mut kept
};
let (stored, fallback) = crate::runtime::stored_key_candidates(field);
into.push(stored);
into.extend(fallback);
}
LevelKeys::Only { kept, masked }
}
fn stored_key(schema: &CompiledSchema, type_name: &str, column: &str) -> String {
let declared = schema
.find_type(type_name)
.and_then(|t: &TypeDefinition| t.field_for_column(column))
.map_or(column, |field| field.name.as_str());
crate::utils::to_snake_case(declared)
}
fn declared_relationship(
schema: &CompiledSchema,
parent_type: &str,
name: &str,
) -> Result<Relationship> {
let parent = schema.find_type(parent_type).ok_or_else(|| FraiseQLError::Validation {
message: format!("Type '{parent_type}' not found in schema"),
path: None,
})?;
parent.relationships.iter().find(|r| r.name == name).cloned().ok_or_else(|| {
FraiseQLError::Validation {
message: format!("Type '{parent_type}' has no relationship '{name}'"),
path: None,
}
})
}
pub(super) fn list_query_for_type<'a>(
schema: &'a CompiledSchema,
type_name: &str,
) -> Option<&'a QueryDefinition> {
schema
.queries
.iter()
.find(|q| q.return_type == type_name && q.returns_list && q.function.is_none())
}
fn level_query_match(
schema: &CompiledSchema,
target_query: &QueryDefinition,
fields: Vec<String>,
filter: Option<&serde_json::Value>,
limit: Option<u32>,
) -> Result<QueryMatch> {
let mut arguments: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(filter) = filter.filter(|f| f.as_object().is_some_and(|m| !m.is_empty())) {
arguments.insert("where".to_string(), filter.clone());
}
if let Some(limit) = limit {
arguments.insert("limit".to_string(), serde_json::json!(limit));
}
let level = QueryMatch::from_operation(
target_query.clone(),
fields,
arguments,
schema.find_type(&target_query.return_type),
)?;
Ok(super::query_nested::expand_leaf_objects(schema, &level)?.into_owned())
}
#[cfg(test)]
#[path = "query_composed_tests.rs"]
mod query_composed_tests;