Skip to main content

fraiseql_core/schema/
derived_inputs.rs

1//! The filter and sort input types FraiseQL derives from a compiled schema.
2//!
3//! `where` and `orderBy` are auto-wired arguments: a query enables them through
4//! [`AutoParams`](super::AutoParams) and the runtime reads their raw value off
5//! the argument map, so they are deliberately absent from
6//! [`QueryDefinition::arguments`](super::QueryDefinition::arguments). Until
7//! v2.15.0 they were also *typed* as the `JSON` scalar, which made the
8//! conventional names every client writes — `OrderWhereInput`,
9//! `OrderOrderByInput` — resolve against nothing, and § 5.8.2 refused documents
10//! that were correct in every other respect.
11//!
12//! This module makes those names real. It is the **only** place they are
13//! spelled, so the name a query publishes and the type a schema defines cannot
14//! drift: [`CompiledSchema::build_indexes`](super::CompiledSchema::build_indexes)
15//! materialises what [`derive()`] returns, and
16//! [`QueryDefinition::graphql_arguments`](super::QueryDefinition::graphql_arguments)
17//! types an argument **iff** the schema carries the type it would name.
18//!
19//! # What is not derived, and why
20//!
21//! Deriving a filter is advertising an operator. #869 deleted a 48-type
22//! `<RichType>WhereInput` surface that advertised 35 operator names the WHERE
23//! parser could not serve, two of which silently bound to unrelated operators.
24//! Every rule below is the same rule: **do not advertise what the engine cannot
25//! run, and do not advertise a name no type backs.**
26//!
27//! * The **fulltext, network and ltree** operator families are not bucketed onto any leaf.
28//!   `matches` needs a `tsvector`, `is_private` an `inet`, `ancestor_of` an `ltree`; a declared
29//!   field type cannot say whether the column behind it is one. They stay executable — nothing
30//!   coerces argument values — but they are not promised.
31//! * A **list of relations** gets a list filter, not a nested entity filter. The engine lowers
32//!   `{lines: {sku: {eq: …}}}` to `data->'lines'->>'sku'`, which cannot index into an array and so
33//!   matches nothing, silently.
34//! * An `Object(T)` that **no type declares** is an opaque leaf, not a relation. Authoring layers
35//!   emit these — one real schema carries `datetime`, `date`, `dict`, `IPAddress`, `Hostname`,
36//!   `LTree`, `MACAddress` and `CIDR` — and the runtime already treats them as text.
37//! * A name the **author declared** is never derived over.
38//!
39//! # This is advertisement, not enforcement
40//!
41//! Per-scalar filters are stricter than the engine, which restricts no operator
42//! by field type. Nothing coerces a query argument's value against its declared
43//! input type (`arg_type` is read at runtime only on the mutation path), so a
44//! stricter published type refuses nothing at execution — it constrains clients
45//! that validate locally against introspection.
46
47use std::{
48    collections::{BTreeMap, HashMap},
49    sync::Arc,
50};
51
52use fraiseql_db::where_clause::{RelationFieldMaps, WhereFieldInfo};
53
54use super::{
55    CompiledSchema, EnumDefinition, EnumValueDefinition, FieldType, InputFieldDefinition,
56    InputObjectDefinition,
57};
58
59/// The enum a derived `orderBy` item's `direction` is typed with.
60pub const SORT_DIRECTION_ENUM: &str = "SortDirection";
61
62/// Comparison operators, advertised on every leaf.
63pub const COMPARISON_OPERATORS: &[&str] = &["eq", "neq", "gt", "gte", "lt", "lte", "in", "nin"];
64
65/// NULL checks, advertised on every leaf.
66pub const NULL_OPERATORS: &[&str] = &["isnull", "is_not_null"];
67
68/// LIKE/ILIKE/regex operators, advertised on text-backed leaves.
69pub const STRING_OPERATORS: &[&str] = &[
70    "contains",
71    "icontains",
72    "startswith",
73    "istartswith",
74    "endswith",
75    "iendswith",
76    "like",
77    "ilike",
78    "nlike",
79    "nilike",
80    "regex",
81    "iregex",
82    "nregex",
83    "niregex",
84];
85
86/// JSONB array containment and length operators, advertised on lists and `JSON`.
87pub const ARRAY_OPERATORS: &[&str] = &[
88    "array_contains",
89    "array_contained_by",
90    "array_overlaps",
91    "len_eq",
92    "len_neq",
93    "len_gt",
94    "len_gte",
95    "len_lt",
96    "len_lte",
97];
98
99/// JSONB containment, advertised on `JSON`.
100pub const CONTAINMENT_OPERATORS: &[&str] = &["strictly_contains"];
101
102/// pgvector distance operators, advertised on the vector leaves.
103pub const VECTOR_OPERATORS: &[&str] = &[
104    "cosine_distance",
105    "l2_distance",
106    "l1_distance",
107    "hamming_distance",
108    "inner_product",
109    "jaccard_distance",
110];
111
112/// The combinators [`WhereClause::from_graphql_json`] implements — and the only
113/// ones it implements.
114///
115/// v1 emitted `AND`/`OR`/`NOT`. Publishing that spelling against this engine
116/// would advertise three fields no request can execute.
117///
118/// [`WhereClause::from_graphql_json`]: fraiseql_db::where_clause::WhereClause::from_graphql_json
119pub const COMBINATORS: [&str; 3] = ["_and", "_or", "_not"];
120
121/// The input objects and enums a schema's auto-wired filter/sort surface needs.
122#[derive(Debug, Default, Clone, PartialEq)]
123pub struct DerivedInputs {
124    /// Entity filters, scalar filters and `orderBy` items, in a deterministic order.
125    pub input_types: Vec<InputObjectDefinition>,
126
127    /// [`SORT_DIRECTION_ENUM`], when a sort surface was derived and the schema
128    /// does not already declare it.
129    pub enums: Vec<EnumDefinition>,
130}
131
132/// The type name a `where` argument on `entity` publishes.
133#[must_use]
134pub fn where_input_type_name(entity: &str) -> String {
135    format!("{entity}WhereInput")
136}
137
138/// The item type name an `orderBy` argument on `entity` publishes.
139#[must_use]
140pub fn order_by_input_type_name(entity: &str) -> String {
141    format!("{entity}OrderByInput")
142}
143
144/// Everything the schema's `where`/`orderBy` surface needs and does not already
145/// declare.
146///
147/// Deterministic: entity filters follow the breadth-first walk from the queries
148/// in declaration order, `orderBy` items follow query order, and scalar filters
149/// are sorted by name.
150#[must_use]
151pub fn derive(schema: &CompiledSchema) -> DerivedInputs {
152    let mut leaves: BTreeMap<String, LeafFilter> = BTreeMap::new();
153    let mut entities: Vec<InputObjectDefinition> = Vec::new();
154    let mut walked: Vec<String> = Vec::new();
155
156    // Breadth-first from every entity a `where`-enabled query returns, following
157    // single-object relations. The walk continues through an entity whose filter
158    // the author declared — their type is kept, but the entities it can reach
159    // still need theirs.
160    let mut queue: Vec<String> = filterable_entities(schema);
161    while let Some(entity) = pop_front(&mut queue) {
162        if walked.contains(&entity) {
163            continue;
164        }
165        let Some(type_def) = adjudicable_type(schema, &entity) else {
166            continue;
167        };
168        walked.push(entity.clone());
169
170        let mut fields = Vec::new();
171        for field in &type_def.fields {
172            match classify(schema, &field.field_type) {
173                Slot::Relation(target) => {
174                    queue.push(target.clone());
175                    fields.push(
176                        InputFieldDefinition::new(
177                            field.name.as_str(),
178                            where_input_type_name(&target),
179                        )
180                        .with_description(format!("Filter by the related `{target}`.")),
181                    );
182                },
183                Slot::Leaf(leaf) => {
184                    let type_name = leaf.type_name();
185                    fields.push(
186                        InputFieldDefinition::new(field.name.as_str(), type_name.clone())
187                            .with_description(format!("Filter on `{}`.", field.name)),
188                    );
189                    leaves.entry(type_name).or_insert(leaf);
190                },
191                // Omission is the safe direction: an interface- or union-typed
192                // field has no filter shape this can justify, and inventing one
193                // would advertise a predicate the engine cannot honour.
194                Slot::Unfilterable => {},
195            }
196        }
197
198        let name = where_input_type_name(&entity);
199        if schema.find_input_type(&name).is_some() {
200            continue;
201        }
202        fields.extend(combinator_fields(&name));
203        entities.push(InputObjectDefinition::new(name).with_fields(fields).with_description(
204            format!(
205                "Filter predicate over `{entity}`. Top-level keys are combined with AND; \
206                     `_and`/`_or`/`_not` nest explicitly."
207            ),
208        ));
209    }
210
211    let mut input_types = entities;
212    input_types.extend(order_by_inputs(schema));
213    // A leaf filter is collected while walking an entity, including an entity
214    // whose own filter is already present — so the "already declared" check has
215    // to happen here rather than at collection time, or a second `build_indexes`
216    // would append every leaf again.
217    input_types.extend(
218        leaves
219            .into_values()
220            .filter(|leaf| schema.find_input_type(&leaf.type_name()).is_none())
221            .map(LeafFilter::into_input_object),
222    );
223
224    let mut enums = Vec::new();
225    if input_types.iter().any(|i| i.name.ends_with("OrderByInput"))
226        && schema.find_enum(SORT_DIRECTION_ENUM).is_none()
227    {
228        enums.push(
229            EnumDefinition::new(SORT_DIRECTION_ENUM)
230                .with_value(EnumValueDefinition::new("ASC"))
231                .with_value(EnumValueDefinition::new("DESC"))
232                .with_description("Sort direction."),
233        );
234    }
235
236    DerivedInputs { input_types, enums }
237}
238
239/// The `orderBy` item type for every entity a sortable query returns.
240///
241/// Follows queries rather than the relation closure: `orderBy` applies at the
242/// query's own level and never nests.
243fn order_by_inputs(schema: &CompiledSchema) -> Vec<InputObjectDefinition> {
244    let mut out: Vec<InputObjectDefinition> = Vec::new();
245    for query in &schema.queries {
246        if query.relay || !query.auto_params.has_order_by {
247            continue;
248        }
249        let name = order_by_input_type_name(&query.return_type);
250        if schema.find_input_type(&name).is_some() || out.iter().any(|i| i.name == name) {
251            continue;
252        }
253        if adjudicable_type(schema, &query.return_type).is_none() {
254            continue;
255        }
256        out.push(
257            InputObjectDefinition::new(name)
258                .with_fields(vec![
259                    // `String!`, not an enum of sortable fields:
260                    // `enrich_order_by_clauses` accepts a declared field *or* a
261                    // native column, and a native column need not be a declared
262                    // field. An enum would advertise a narrower sort surface
263                    // than the engine's on exactly the queries compiled with
264                    // `--database`.
265                    InputFieldDefinition::new("field", "String")
266                        .with_nullable(false)
267                        .with_description("Field to sort by."),
268                    InputFieldDefinition::new("direction", SORT_DIRECTION_ENUM)
269                        .with_default_value("ASC")
270                        .with_description("Sort direction; ascending when omitted."),
271                ])
272                .with_description(format!("One sort key over `{}`.", query.return_type)),
273        );
274    }
275    out
276}
277
278/// Entities a `where`-enabled query returns.
279///
280/// Relay connections are excluded: their argument surface is owned by each
281/// renderer's relay path, and `graphql_arguments` returns them unchanged.
282fn filterable_entities(schema: &CompiledSchema) -> Vec<String> {
283    schema
284        .queries
285        .iter()
286        .filter(|q| !q.relay && q.auto_params.has_where)
287        .map(|q| q.return_type.clone())
288        .collect()
289}
290
291/// The type definition backing `name`, when the schema can adjudicate its
292/// fields.
293///
294/// A type that is absent, or present with no fields, means the compiler emitted
295/// no field information — not that the entity has no fields. Deriving a filter
296/// from that absence would publish an input object that positively forbids every
297/// key the engine still accepts (#939).
298fn adjudicable_type<'a>(
299    schema: &'a CompiledSchema,
300    name: &str,
301) -> Option<&'a super::TypeDefinition> {
302    schema.find_type(name).filter(|t| !t.fields.is_empty())
303}
304
305fn combinator_fields(self_name: &str) -> Vec<InputFieldDefinition> {
306    vec![
307        InputFieldDefinition::new("_and", format!("[{self_name}!]"))
308            .with_description("Every predicate in the list must match."),
309        InputFieldDefinition::new("_or", format!("[{self_name}!]"))
310            .with_description("At least one predicate in the list must match."),
311        // Typed, not `JSON`: v1 left `NOT` untyped while typing `AND`/`OR`, and
312        // an untyped hole in a typed surface is where the next silent wrong
313        // answer lives.
314        InputFieldDefinition::new("_not", self_name)
315            .with_description("The predicate must not match."),
316    ]
317}
318
319/// Pop from the front, keeping the walk breadth-first and its output stable.
320fn pop_front(queue: &mut Vec<String>) -> Option<String> {
321    if queue.is_empty() {
322        return None;
323    }
324    Some(queue.remove(0))
325}
326
327/// What a field of a given type contributes to its entity's filter.
328enum Slot {
329    /// A nested entity predicate: `{T}WhereInput`.
330    Relation(String),
331    /// An operator bag over a leaf type.
332    Leaf(LeafFilter),
333    /// No filter shape this can justify; the field is left out of the surface.
334    Unfilterable,
335}
336
337/// Which operator families a leaf advertises beyond comparison and NULL.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
339struct Ops {
340    /// LIKE/ILIKE/regex — text-backed leaves.
341    string:      bool,
342    /// JSONB array containment and length.
343    array:       bool,
344    /// JSONB containment.
345    containment: bool,
346    /// pgvector distances.
347    vector:      bool,
348}
349
350impl Ops {
351    const fn none() -> Self {
352        Self {
353            string:      false,
354            array:       false,
355            containment: false,
356            vector:      false,
357        }
358    }
359
360    const fn string() -> Self {
361        Self {
362            string: true,
363            ..Self::none()
364        }
365    }
366
367    const fn array() -> Self {
368        Self {
369            array: true,
370            ..Self::none()
371        }
372    }
373
374    const fn json() -> Self {
375        Self {
376            array: true,
377            containment: true,
378            ..Self::none()
379        }
380    }
381
382    const fn vector() -> Self {
383        Self {
384            vector: true,
385            ..Self::none()
386        }
387    }
388
389    /// Every operator name this leaf advertises, in a fixed family order.
390    fn operator_names(self) -> Vec<&'static str> {
391        let mut names: Vec<&'static str> = COMPARISON_OPERATORS.to_vec();
392        names.extend(NULL_OPERATORS);
393        if self.string {
394            names.extend(STRING_OPERATORS);
395        }
396        if self.array {
397            names.extend(ARRAY_OPERATORS);
398        }
399        if self.containment {
400            names.extend(CONTAINMENT_OPERATORS);
401        }
402        if self.vector {
403            names.extend(VECTOR_OPERATORS);
404        }
405        names
406    }
407}
408
409/// An operator bag over one leaf type.
410#[derive(Debug, Clone, PartialEq, Eq)]
411struct LeafFilter {
412    /// Names the generated type: `{key}Filter`.
413    key:     String,
414    /// GraphQL type of a single operand — what `eq` takes.
415    operand: String,
416    ops:     Ops,
417}
418
419impl LeafFilter {
420    fn type_name(&self) -> String {
421        format!("{}Filter", self.key)
422    }
423
424    fn into_input_object(self) -> InputObjectDefinition {
425        let fields = self
426            .ops
427            .operator_names()
428            .into_iter()
429            .map(|op| {
430                InputFieldDefinition::new(op, operand_type(op, &self.operand))
431                    .with_description(operator_description(op))
432            })
433            .collect();
434        InputObjectDefinition::new(self.type_name())
435            .with_fields(fields)
436            .with_description(format!(
437                "Filter operators over `{}`. Every key is ANDed.",
438                self.operand
439            ))
440    }
441}
442
443/// The GraphQL type an operator's operand takes.
444///
445/// Follows the operator, not the field: `in` takes a list of the field's type,
446/// a NULL check takes a `Boolean`, a LIKE pattern is always a `String`, a length
447/// comparison an `Int`, and JSONB containment a `JSON` document.
448fn operand_type(op: &str, operand: &str) -> String {
449    if matches!(op, "in" | "nin") {
450        return format!("[{operand}!]");
451    }
452    if NULL_OPERATORS.contains(&op) {
453        return "Boolean".to_string();
454    }
455    if STRING_OPERATORS.contains(&op) {
456        return "String".to_string();
457    }
458    if op.starts_with("len_") {
459        return "Int".to_string();
460    }
461    if ARRAY_OPERATORS.contains(&op) || CONTAINMENT_OPERATORS.contains(&op) {
462        return "JSON".to_string();
463    }
464    operand.to_string()
465}
466
467/// One line of prose per operator, taken from the SQL it lowers to.
468fn operator_description(op: &str) -> String {
469    fraiseql_db::where_clause::operator_spec(op)
470        .map_or_else(|| format!("`{op}`"), |spec| format!("`{op}` — SQL `{}`.", spec.sql_op))
471}
472
473/// The entity a nested `where` predicate on a field of this type filters, if the
474/// published surface gives that field a nested filter at all.
475///
476/// This is the runtime's half of the same question [`derive()`] answers when it
477/// gives a field `{Target}WhereInput` instead of an operator bag, and it is
478/// deliberately the *same* function: `WhereClause::from_graphql_json` decides
479/// whether `{field: {sub: …}}` is a legitimate nested predicate, and if it
480/// decided differently from the published type the schema would advertise a
481/// filter the engine refuses, or accept one the schema says is not there.
482///
483/// `None` for a scalar, an enum, an opaque leaf, and for a **list** — the engine
484/// lowers a nested predicate on a list to a JSON path that cannot index into an
485/// array, so it matches nothing, silently.
486#[must_use]
487pub fn nested_filter_entity(schema: &CompiledSchema, field_type: &FieldType) -> Option<String> {
488    match classify(schema, field_type) {
489        Slot::Relation(name) => Some(name),
490        Slot::Leaf(_) | Slot::Unfilterable => None,
491    }
492}
493
494/// The `where` keys of every type a nested predicate can descend into, keyed by
495/// declared type name.
496///
497/// Built once per compiled schema and carried on it, because a nested level is
498/// adjudicated against the *target* type's keys and rebuilding the closure per
499/// request would put a map-per-type allocation in front of every filter.
500#[must_use]
501pub fn relation_field_maps(schema: &CompiledSchema) -> RelationFieldMaps {
502    Arc::new(
503        schema
504            .types
505            .iter()
506            .filter(|t| !t.fields.is_empty())
507            .map(|t| (t.name.to_string(), Arc::new(where_keys_of(schema, t))))
508            .collect(),
509    )
510}
511
512/// The `where` keys one type declares, in the shape the parser adjudicates
513/// against.
514pub(crate) fn where_keys_of(
515    schema: &CompiledSchema,
516    type_def: &super::TypeDefinition,
517) -> HashMap<String, WhereFieldInfo> {
518    type_def
519        .fields
520        .iter()
521        .map(|f| {
522            let relation_type = nested_filter_entity(schema, &f.field_type);
523            (
524                crate::utils::to_snake_case(f.name.as_str()),
525                WhereFieldInfo {
526                    declared_name: f.name.to_string(),
527                    is_relation: relation_type.is_some(),
528                    relation_type,
529                    // #1157: the cast travels with the key, so a nested path can
530                    // be typed at the level the parser reaches it.
531                    cast: Some(crate::runtime::field_type_to_where_type(&f.field_type)),
532                    // #1396: the hierarchy travels with the key too, so the parser can
533                    // attach it to `descendantOfId` / `ancestorOfId` on this field. A link
534                    // to an undeclared hierarchy is refused when the schema loads.
535                    hierarchy: f.hierarchy.as_deref().and_then(|name| {
536                        schema.hierarchies_config.as_ref()?.get(name).map(|h| {
537                            fraiseql_db::where_generator::HierarchyContext {
538                                table:       h.table.clone(),
539                                path_column: h.path_column.clone(),
540                                fk_column:   None,
541                            }
542                        })
543                    }),
544                },
545            )
546        })
547        .collect()
548}
549
550/// The filter slot a field of `field_type` occupies.
551fn classify(schema: &CompiledSchema, field_type: &FieldType) -> Slot {
552    let leaf = |key: &str, operand: &str, ops: Ops| {
553        Slot::Leaf(LeafFilter {
554            key: key.to_string(),
555            operand: operand.to_string(),
556            ops,
557        })
558    };
559
560    match field_type {
561        FieldType::Int => leaf("Int", "Int", Ops::none()),
562        FieldType::Float => leaf("Float", "Float", Ops::none()),
563        FieldType::Decimal => leaf("Decimal", "Decimal", Ops::none()),
564        FieldType::Boolean => leaf("Boolean", "Boolean", Ops::none()),
565        FieldType::String => leaf("String", "String", Ops::string()),
566        FieldType::Id => leaf("ID", "ID", Ops::none()),
567        FieldType::Uuid => leaf("UUID", "UUID", Ops::none()),
568        FieldType::DateTime => leaf("DateTime", "DateTime", Ops::none()),
569        FieldType::Date => leaf("Date", "Date", Ops::none()),
570        FieldType::Time => leaf("Time", "Time", Ops::none()),
571        FieldType::Json => leaf("JSON", "JSON", Ops::json()),
572        // A dense vector introspects as `JSON`; a bit or sparse vector as the
573        // `String` that is its own text form (#959).
574        FieldType::Vector => leaf("Vector", "JSON", Ops::vector()),
575        FieldType::HalfVector => leaf("HalfVector", "JSON", Ops::vector()),
576        FieldType::BitVector => leaf("BitVector", "String", Ops::vector()),
577        FieldType::SparseVector => leaf("SparseVector", "String", Ops::vector()),
578        // A rich scalar is stored as TEXT, so the text operators are the ones
579        // that actually run against it.
580        FieldType::Scalar(name) => leaf(name, name, Ops::string()),
581        FieldType::Enum(name) => leaf(name, name, Ops::none()),
582        FieldType::Object(name) => match adjudicable_type(schema, name) {
583            Some(_) => Slot::Relation(name.clone()),
584            // No type declares it, so it is not an entity. The runtime already
585            // compares it as text.
586            None => leaf(name, name, Ops::string()),
587        },
588        FieldType::List(inner) => match classify(schema, inner) {
589            Slot::Leaf(inner_leaf) => Slot::Leaf(LeafFilter {
590                key:     format!("{}List", inner_leaf.key),
591                operand: format!("[{}!]", inner_leaf.operand),
592                ops:     Ops::array(),
593            }),
594            // A list of a declared entity: a list filter, never a nested entity
595            // filter (see the module header).
596            Slot::Relation(name) => Slot::Leaf(LeafFilter {
597                key:     format!("{name}List"),
598                operand: "JSON".to_string(),
599                ops:     Ops::array(),
600            }),
601            Slot::Unfilterable => Slot::Unfilterable,
602        },
603        // An interface or union field has no single filter shape, and an output
604        // field typed as an *input* object is malformed to begin with. Omission
605        // is the safe direction: it narrows what is advertised, never what the
606        // engine accepts.
607        FieldType::Interface(_) | FieldType::Union(_) | FieldType::Input(_) => Slot::Unfilterable,
608    }
609}
610
611#[cfg(test)]
612#[path = "derived_inputs_tests.rs"]
613mod tests;