use std::sync::Arc;
use crate::{
backend::{
OrderByClause, ProjectionField, ScalarFieldType,
projection_generator::FieldKind,
where_clause::{SharedFieldTypes, WhereFieldSchema},
},
graphql::{FieldSelection, selection_set::ProjectionKey},
schema::CompiledSchema,
};
pub fn build_typed_projection_fields(
selections: &[FieldSelection],
schema: &CompiledSchema,
parent_type_name: &str,
key: &ProjectionKey,
) -> Vec<ProjectionField> {
let type_def = schema.find_type(parent_type_name);
let request_chain = key.request_chain.as_deref();
selections
.iter()
.filter_map(|sel| {
if sel.name == "__typename" {
return type_def
.map(|_| ProjectionField::typename(sel.response_key(), parent_type_name));
}
if type_def.is_some() && key.masks(parent_type_name, &sel.name) {
let list = type_def.and_then(|td| td.translations_of(&sel.name)).is_some();
return Some(ProjectionField::masked(sel.response_key(), list));
}
if let Some(base) = type_def.and_then(|td| td.translations_of(&sel.name)) {
return Some(ProjectionField {
name: sel.response_key().to_string(),
source: base.name.to_string(),
kind: FieldKind::Composite,
sub_fields: None,
localized: crate::runtime::translations_read(schema, sel),
computed: None,
});
}
let field_def =
type_def.and_then(|td| td.fields.iter().find(|f| f.name == sel.name.as_str()));
let is_composite = field_def.is_some_and(|fd| !fd.field_type.is_scalar());
let is_text = field_def.is_some_and(|fd| {
matches!(
fd.field_type,
crate::schema::FieldType::String
| crate::schema::FieldType::Id
| crate::schema::FieldType::Decimal
)
});
let element = field_def
.and_then(|fd| fd.field_type.inner_type())
.filter(|item| item.is_object())
.and_then(crate::schema::FieldType::type_name);
let kind = if element.is_some() {
FieldKind::ObjectList
} else if is_composite {
FieldKind::Composite
} else if is_text {
FieldKind::Text
} else {
FieldKind::Native
};
let sub_fields = field_def
.filter(|fd| {
matches!(
fd.field_type,
crate::schema::FieldType::Object(_)
| crate::schema::FieldType::Interface(_)
| crate::schema::FieldType::Union(_)
)
})
.and_then(|fd| fd.field_type.type_name())
.or(element)
.map(|child_type| {
build_typed_projection_fields(&sel.nested_fields, schema, child_type, key)
});
Some(ProjectionField {
name: sel.response_key().to_string(),
source: sel.name.clone(),
kind,
sub_fields,
localized: field_def
.filter(|fd| fd.localized)
.and_then(|_| crate::runtime::selection_chain_in(schema, sel, request_chain))
.map(fraiseql_db::LocalizedRead::Label),
computed: None,
})
})
.collect()
}
#[must_use]
pub fn projects_decimals_as_text(
fields: &[ProjectionField],
schema: &CompiledSchema,
parent_type_name: &str,
decimals: &crate::runtime::DecimalIndex,
) -> bool {
let type_def = schema.find_type(parent_type_name);
fields.iter().all(|field| {
if field.computed.is_some() || field.localized.is_some() {
return true;
}
let Some(field_def) =
type_def.and_then(|td| td.fields.iter().find(|f| f.name == field.source.as_str()))
else {
return false;
};
match (&field.kind, &field_def.field_type, &field.sub_fields) {
(FieldKind::Text, _, _) => true,
(FieldKind::Composite, crate::schema::FieldType::Object(child), Some(sub_fields)) => {
projects_decimals_as_text(sub_fields, schema, child, decimals)
},
(FieldKind::ObjectList, list, Some(sub_fields)) => {
list.inner_type().and_then(crate::schema::FieldType::type_name).is_some_and(
|child| projects_decimals_as_text(sub_fields, schema, child, decimals),
)
},
_ => !decimals.holds_decimal(&field_def.field_type),
}
})
}
#[must_use]
pub fn where_field_types(schema: &CompiledSchema, return_type: &str) -> WhereFieldSchema {
where_field_types_reading(schema, return_type, &std::collections::HashMap::new())
}
#[must_use]
pub fn where_field_types_reading(
schema: &CompiledSchema,
return_type: &str,
native_columns: &std::collections::HashMap<String, crate::schema::NativeColumn>,
) -> WhereFieldSchema {
let Some(type_def) = schema.find_type(return_type) else {
return WhereFieldSchema::default();
};
if type_def.fields.is_empty() {
return WhereFieldSchema::default();
}
let columns = type_def
.fields
.iter()
.filter_map(|f| {
where_native_column(f, native_columns)
.map(|column| (crate::utils::to_snake_case(f.name.as_str()), column))
})
.collect::<Vec<_>>();
let casts: SharedFieldTypes = Arc::new(
type_def
.fields
.iter()
.map(|f| {
(
crate::utils::to_snake_case(f.name.as_str()),
field_type_to_where_type(&f.field_type),
)
})
.collect::<fraiseql_db::where_clause::FieldTypeMap>()
.with_columns(columns),
);
let known = crate::schema::derived_inputs::where_keys_of(schema, type_def);
let fields = WhereFieldSchema::with_relations(
casts,
known,
Arc::clone(&schema.derived.where_relation_fields),
);
match (
crate::runtime::localization_chain(schema),
crate::runtime::request_locale(schema),
) {
(Some(chain), Some(locale)) => {
fields.with_locale(fraiseql_db::where_clause::LocaleReading {
chain,
collation: schema.locale.as_ref().and_then(|l| l.collation(&locale)),
})
},
_ => fields,
}
}
fn where_native_column(
field: &crate::schema::FieldDefinition,
native_columns: &std::collections::HashMap<String, crate::schema::NativeColumn>,
) -> Option<String> {
use crate::schema::FieldType as FT;
if field.localized {
return None;
}
let key = crate::utils::to_snake_case(field.name.as_str());
let native = native_columns
.iter()
.find_map(|(name, native)| (crate::utils::to_snake_case(name) == key).then_some(native))?;
let pg_type = native.pg_type.to_ascii_lowercase();
let fits = match &field.field_type {
FT::String | FT::Enum(_) => {
matches!(pg_type.as_str(), "text" | "varchar" | "character varying")
},
FT::Int => matches!(
pg_type.as_str(),
"smallint" | "integer" | "int" | "bigint" | "int2" | "int4" | "int8"
),
FT::Decimal => matches!(pg_type.as_str(), "numeric" | "decimal"),
FT::Boolean => matches!(pg_type.as_str(), "boolean" | "bool"),
FT::DateTime => matches!(pg_type.as_str(), "timestamptz" | "timestamp with time zone"),
FT::Date => pg_type == "date",
FT::Time => matches!(pg_type.as_str(), "time" | "time without time zone"),
FT::Id | FT::Uuid => pg_type == "uuid",
_ => false,
};
fits.then_some(key)
}
const fn field_type_to_order_by_type(ft: &crate::schema::FieldType) -> ScalarFieldType {
scalar_cast_hint(ft)
}
pub const fn field_type_to_where_type(ft: &crate::schema::FieldType) -> ScalarFieldType {
use crate::schema::FieldType as FT;
match ft {
FT::Id | FT::Uuid => ScalarFieldType::Uuid,
FT::Vector | FT::BitVector | FT::HalfVector | FT::SparseVector => ScalarFieldType::Vector,
_ => scalar_cast_hint(ft),
}
}
const fn scalar_cast_hint(ft: &crate::schema::FieldType) -> ScalarFieldType {
use crate::schema::FieldType as FT;
match ft {
FT::Int => ScalarFieldType::Integer,
FT::Float | FT::Decimal => ScalarFieldType::Numeric,
FT::Boolean => ScalarFieldType::Boolean,
FT::DateTime => ScalarFieldType::DateTime,
FT::Date => ScalarFieldType::Date,
FT::Time => ScalarFieldType::Time,
_ => ScalarFieldType::Text,
}
}
pub fn enrich_order_by_clauses(
mut clauses: Vec<OrderByClause>,
schema: &CompiledSchema,
return_type: &str,
native_columns: &std::collections::HashMap<String, crate::schema::NativeColumn>,
security_context: Option<&crate::security::SecurityContext>,
suggestions: bool,
) -> crate::error::Result<Vec<OrderByClause>> {
let type_def = schema.find_type(return_type);
let sortable =
crate::schema::derived_inputs::sortable_keys(schema, return_type, native_columns);
let collation = schema
.locale
.as_ref()
.zip(crate::runtime::request_locale(schema))
.and_then(|(config, locale)| config.collation(&locale));
for clause in &mut clauses {
if let Some(keys) = &sortable {
if !keys.contains(&clause.field) {
return Err(unknown_sort_field(&clause.field, return_type, keys, suggestions));
}
}
if let Some(td) = type_def {
if let Some(field_def) = td.find_field(&clause.field) {
super::super::support::security::refuse_unreadable_reference(
schema,
return_type,
field_def,
security_context,
"order by",
)?;
clause.field_type = field_type_to_order_by_type(&field_def.field_type);
if matches!(field_def.field_type, crate::schema::FieldType::String)
&& native_column_of(native_columns, clause)
.is_none_or(|native| sorts_as_text(&native.pg_type))
{
clause.collation.clone_from(&collation);
}
if field_def.localized {
clause.localized = crate::runtime::localization_chain(schema);
}
}
}
if let Some(native) = native_column_of(native_columns, clause) {
clause.native_type = Some(native.pg_type.clone());
clause.native_not_null = native.not_null;
clause.native_column = Some(clause.storage_key());
}
}
Ok(clauses)
}
fn native_column_of<'a>(
native_columns: &'a std::collections::HashMap<String, crate::schema::NativeColumn>,
clause: &OrderByClause,
) -> Option<&'a crate::schema::NativeColumn> {
native_columns
.get(&clause.field)
.or_else(|| native_columns.get(&clause.storage_key()))
}
pub(super) fn sorts_as_text(cast: &str) -> bool {
matches!(
cast.to_ascii_lowercase().as_str(),
"" | "text" | "varchar" | "character varying" | "bpchar" | "character" | "citext"
)
}
fn unknown_sort_field(
field: &str,
return_type: &str,
sortable: &[String],
suggestions: bool,
) -> crate::error::FraiseQLError {
let candidates: Vec<&str> = sortable.iter().map(String::as_str).collect();
let subject = format!("Cannot sort by '{field}' on type '{return_type}'.");
let message =
match super::super::super::suggestions_for(suggestions, field, &candidates).as_slice() {
[s] => format!("{subject} Did you mean '{s}'?"),
[a, b] => format!("{subject} Did you mean '{a}' or '{b}'?"),
[a, b, c, ..] => format!("{subject} Did you mean '{a}', '{b}', or '{c}'?"),
_ => subject,
};
crate::error::FraiseQLError::Validation {
message,
path: Some(format!("orderBy.{field}")),
}
}
pub fn selections_contain_field(selections: &[FieldSelection], field_name: &str) -> bool {
for sel in selections {
if sel.name == field_name {
return true;
}
if sel.name.starts_with("...") && selections_contain_field(&sel.nested_fields, field_name) {
return true;
}
}
false
}
#[cfg(test)]
#[path = "query_projection_tests.rs"]
mod query_projection_tests;
pub fn vector_distance_projection_fields(
selections: &[FieldSelection],
schema: &CompiledSchema,
parent_type_name: &str,
nearest: Option<&OrderByClause>,
) -> crate::error::Result<Vec<ProjectionField>> {
let Some(type_def) = schema.find_type(parent_type_name) else {
return Ok(Vec::new());
};
let mut fields = Vec::new();
for sel in selections {
let Some(field_def) = type_def.fields.iter().find(|f| f.name == sel.name.as_str()) else {
continue;
};
let Some(measures) = field_def.vector_distance.as_deref() else {
continue;
};
let Some(clause) = nearest else {
return Err(crate::error::FraiseQLError::validation(format!(
"'{parent_type_name}.{}' is the distance to '{measures}' and is only defined \
on a query that searches it; add a `nearest` argument or drop the field",
field_def.name
)));
};
if clause.field != measures {
return Err(crate::error::FraiseQLError::validation(format!(
"'{parent_type_name}.{}' is the distance to '{measures}', but this query \
searched '{}'",
field_def.name, clause.field
)));
}
let expr = crate::backend::order_by::vector_distance_expr(
clause,
crate::backend::DatabaseType::PostgreSQL,
)?
.ok_or_else(|| {
crate::error::FraiseQLError::internal("a nearest clause carried no vector operand")
})?;
fields.push(ProjectionField::computed(sel.response_key(), expr));
}
Ok(fields)
}
pub fn merge_computed_fields(typed: &mut Vec<ProjectionField>, computed: Vec<ProjectionField>) {
for field in computed {
if let Some(slot) = typed.iter_mut().find(|f| f.name == field.name) {
*slot = field;
} else {
typed.push(field);
}
}
}
#[cfg(test)]
mod cast_hint_characterisation {
use super::*;
use crate::schema::FieldType as FT;
fn representative_field_types() -> Vec<FT> {
vec![
FT::String,
FT::Int,
FT::Float,
FT::Boolean,
FT::Id,
FT::DateTime,
FT::Date,
FT::Time,
FT::Json,
FT::Uuid,
FT::Decimal,
]
}
#[test]
fn the_where_and_order_by_cast_hints_diverge_only_for_identity_fields() {
for ft in representative_field_types() {
let where_ty = field_type_to_where_type(&ft);
let order_ty = field_type_to_order_by_type(&ft);
if matches!(ft, FT::Id | FT::Uuid) {
assert_eq!(where_ty, ScalarFieldType::Uuid, "identity filters compare as identity");
assert_eq!(order_ty, ScalarFieldType::Text, "identity sorts stay text ordering");
} else {
assert_eq!(
where_ty, order_ty,
"only identities may diverge; {ft:?} did — intended? then update this"
);
}
}
}
#[test]
fn identity_fields_compare_as_identities_not_as_text() {
assert_eq!(field_type_to_where_type(&FT::Id), ScalarFieldType::Uuid);
assert_eq!(field_type_to_where_type(&FT::Uuid), ScalarFieldType::Uuid);
assert_ne!(
field_type_to_where_type(&FT::String),
ScalarFieldType::Uuid,
"a plain string that happens to hold a UUID must keep text semantics"
);
}
#[test]
fn identity_fields_sort_as_text_and_that_is_correct() {
assert_eq!(field_type_to_order_by_type(&FT::Id), ScalarFieldType::Text);
assert_eq!(field_type_to_order_by_type(&FT::Uuid), ScalarFieldType::Text);
}
#[test]
fn strongly_typed_scalars_keep_their_casts_on_both_sides() {
assert_eq!(field_type_to_where_type(&FT::Int), ScalarFieldType::Integer);
assert_eq!(field_type_to_where_type(&FT::Decimal), ScalarFieldType::Numeric);
assert_eq!(field_type_to_where_type(&FT::DateTime), ScalarFieldType::DateTime);
assert_eq!(field_type_to_order_by_type(&FT::Int), ScalarFieldType::Integer);
assert_eq!(field_type_to_order_by_type(&FT::Decimal), ScalarFieldType::Numeric);
assert_eq!(field_type_to_order_by_type(&FT::DateTime), ScalarFieldType::DateTime);
}
}
#[cfg(test)]
mod order_by_validation {
use std::collections::HashMap;
use super::*;
use crate::{
backend::OrderByClause,
schema::{FieldDefinition, FieldType, TypeDefinition},
};
fn schema_with_order() -> CompiledSchema {
let mut schema = CompiledSchema::default();
let mut order = TypeDefinition::new("Order", "v_order");
order.fields.push(FieldDefinition::new("reference", FieldType::String));
order.fields.push(FieldDefinition::new("total", FieldType::Decimal));
schema.types.push(order);
schema
}
fn clause(field: &str) -> Vec<OrderByClause> {
vec![OrderByClause::new(
field.to_string(),
crate::backend::OrderDirection::Asc,
)]
}
#[test]
fn an_unknown_sort_field_is_refused() {
let err = enrich_order_by_clauses(
clause("totallyBogusField"),
&schema_with_order(),
"Order",
&HashMap::new(),
None,
true,
)
.expect_err("an unknown sort key silently ordered nothing");
let msg = err.to_string();
assert!(msg.contains("totallyBogusField"), "must name the field: {msg}");
assert!(msg.contains("Order"), "must name the type: {msg}");
}
#[test]
fn a_near_miss_sort_field_gets_a_did_you_mean_hint() {
let err = enrich_order_by_clauses(
clause("referense"),
&schema_with_order(),
"Order",
&HashMap::new(),
None,
true,
)
.expect_err("a typo must be refused");
assert!(err.to_string().contains("Did you mean 'reference'?"), "got: {err}");
}
#[test]
fn a_declared_sort_field_passes_and_keeps_its_cast() {
let enriched = enrich_order_by_clauses(
clause("total"),
&schema_with_order(),
"Order",
&HashMap::new(),
None,
true,
)
.expect("a declared field is a legitimate sort key");
assert_eq!(
enriched[0].field_type,
ScalarFieldType::Numeric,
"the declared type must still drive the cast"
);
}
#[test]
fn a_native_column_that_is_not_a_type_field_passes() {
let mut native = HashMap::new();
native.insert("pk_order".to_string(), crate::schema::NativeColumn::nullable("int4"));
let enriched = enrich_order_by_clauses(
clause("pk_order"),
&schema_with_order(),
"Order",
&native,
None,
true,
)
.expect("a native column is a legitimate sort key");
assert_eq!(
enriched[0].native_column.as_deref(),
Some("pk_order"),
"the native mapping must still be applied"
);
}
#[test]
fn a_native_columns_not_null_proof_reaches_the_clause() {
for not_null in [true, false] {
let mut native = HashMap::new();
native.insert(
"pk_order".to_string(),
crate::schema::NativeColumn {
pg_type: "int4".to_string(),
not_null,
},
);
let enriched = enrich_order_by_clauses(
clause("pk_order"),
&schema_with_order(),
"Order",
&native,
None,
true,
)
.expect("a native column is a legitimate sort key");
assert_eq!(enriched[0].native_not_null, not_null);
}
}
#[test]
fn an_unknown_type_cannot_adjudicate_and_passes() {
enrich_order_by_clauses(
clause("anything"),
&schema_with_order(),
"NoSuchType",
&HashMap::new(),
None,
true,
)
.expect("no type information — a rejection cannot be justified");
}
#[test]
fn a_type_with_no_fields_cannot_adjudicate_and_passes() {
let mut schema = CompiledSchema::default();
schema.types.push(TypeDefinition::new("Order", "v_order"));
enrich_order_by_clauses(clause("anything"), &schema, "Order", &HashMap::new(), None, true)
.expect("an empty field list is absence of evidence, not evidence of absence");
}
}