Skip to main content

fraiseql_core/schema/compiled/
schema_lookup.rs

1//! Lookup and indexing methods for [`CompiledSchema`].
2//!
3//! All `find_*` methods, `build_indexes`, `display_name`, and `operation_count`.
4
5use super::{
6    directive::DirectiveDefinition, mutation::MutationDefinition, query::QueryDefinition,
7    schema::CompiledSchema,
8};
9use crate::schema::{
10    config_types::NamingConvention,
11    graphql_type_defs::{
12        EnumDefinition, InputObjectDefinition, InterfaceDefinition, TypeDefinition, UnionDefinition,
13    },
14    subscription_types::SubscriptionDefinition,
15};
16
17impl CompiledSchema {
18    /// Whether this schema declares any consumer of enriched identity — a
19    /// [`SessionVariableSource::Enrichment`] mapping or an
20    /// [`InjectedParamSource::Enrichment`] parameter (#539).
21    ///
22    /// Computed rather than cached: a cached flag would default to `false` on any
23    /// schema that skipped `build_indexes`, and a security backstop that switches
24    /// itself off on a hand-built fixture is worse than none. The scan is two
25    /// iterator passes over declarations, run once per executor construction.
26    ///
27    /// ⚠ This answers "does anything *read* enriched identity", which is **not**
28    /// the same question as "must this request resolve". Resolution is enforced by
29    /// the transport's producer seam and runs whenever a resolver is configured,
30    /// whether or not anything reads the result — making the fail-closed boundary
31    /// conditional on a declaration is precisely the silent-skip the design fights.
32    /// This is read only by the engine's *backstop*, which asks a narrower question:
33    /// "may a principal that never met a resolver execute here".
34    ///
35    /// [`SessionVariableSource::Enrichment`]: crate::schema::SessionVariableSource::Enrichment
36    /// [`InjectedParamSource::Enrichment`]: crate::schema::InjectedParamSource::Enrichment
37    #[must_use]
38    pub fn declares_enrichment_consumer(&self) -> bool {
39        use crate::schema::{InjectedParamSource, SessionVariableSource};
40
41        self.session_variables
42            .variables
43            .iter()
44            .any(|mapping| matches!(mapping.source, SessionVariableSource::Enrichment { .. }))
45            || self
46                .queries
47                .iter()
48                .flat_map(|q| q.inject_params.values())
49                .chain(self.mutations.iter().flat_map(|m| m.inject_params.values()))
50                .any(|source| matches!(source, InjectedParamSource::Enrichment(_)))
51    }
52
53    /// Build the schema's derived state: O(1) operation lookup indexes, and the
54    /// filter/sort input surface `where`/`orderBy` are typed against.
55    ///
56    /// Called automatically by `from_json()`. Must be called manually after any
57    /// direct mutation of `self.queries`, `self.mutations`, or `self.subscriptions`.
58    ///
59    /// # The derived filter surface
60    ///
61    /// [`derived_inputs::derive`] emits `{Entity}WhereInput`,
62    /// `{Entity}OrderByInput` and their leaf filters; they are appended to
63    /// `input_types`/`enums` so introspection, the federation SDL, § 5.8.2 and
64    /// the client emitters all read one materialised surface rather than four
65    /// re-derivations of it. Nothing is overwritten — a name the author declared
66    /// is never derived over — which is also what makes a second call inert.
67    ///
68    /// They are deliberately *not* part of the compiled artifact. The `where`
69    /// and `orderBy` arguments themselves are synthesized at read time from
70    /// [`auto_params`](crate::schema::QueryDefinition::auto_params) rather than
71    /// stored; their types follow the same rule, so a schema compiled by an
72    /// older CLI gains the surface on load and the artifact's content hash does
73    /// not move.
74    ///
75    /// A bare `serde_json::from_str::<CompiledSchema>` skips this, as it always
76    /// has for the operation indexes. The consequence is a *degraded* surface,
77    /// never a wrong one: `graphql_arguments` types an argument only when the
78    /// schema carries the type, so a schema that was never built falls back to
79    /// `JSON` rather than naming something undefined. Load through
80    /// [`from_json`](Self::from_json) to get the typed surface.
81    ///
82    /// [`derived_inputs::derive`]: crate::schema::derived_inputs::derive
83    pub fn build_indexes(&mut self) {
84        let derived = crate::schema::derived_inputs::derive(self);
85        self.input_types.extend(derived.input_types);
86        self.enums.extend(derived.enums);
87        self.where_relation_fields = crate::schema::derived_inputs::relation_field_maps(self);
88
89        let camel = matches!(self.naming_convention, NamingConvention::CamelCase);
90
91        self.query_index = self
92            .queries
93            .iter()
94            .enumerate()
95            .flat_map(|(i, q)| {
96                let mut entries = vec![(q.name.clone(), i)];
97                if camel {
98                    let converted = crate::utils::casing::to_camel_case(&q.name);
99                    if converted != q.name {
100                        entries.push((converted, i));
101                    }
102                }
103                entries
104            })
105            .collect();
106
107        self.mutation_index = self
108            .mutations
109            .iter()
110            .enumerate()
111            .flat_map(|(i, m)| {
112                let mut entries = vec![(m.name.clone(), i)];
113                if camel {
114                    let converted = crate::utils::casing::to_camel_case(&m.name);
115                    if converted != m.name {
116                        entries.push((converted, i));
117                    }
118                }
119                entries
120            })
121            .collect();
122
123        self.subscription_index = self
124            .subscriptions
125            .iter()
126            .enumerate()
127            .flat_map(|(i, s)| {
128                let mut entries = vec![(s.name.clone(), i)];
129                if camel {
130                    let converted = crate::utils::casing::to_camel_case(&s.name);
131                    if converted != s.name {
132                        entries.push((converted, i));
133                    }
134                }
135                entries
136            })
137            .collect();
138    }
139
140    /// Return the display name for an operation, applying the naming convention.
141    ///
142    /// When `naming_convention` is `CamelCase`, converts `snake_case` names to
143    /// `camelCase` (e.g., `create_dns_server` → `createDnsServer`).
144    /// When `Preserve`, returns the name unchanged.
145    #[must_use]
146    pub fn display_name(&self, name: &str) -> String {
147        match self.naming_convention {
148            NamingConvention::CamelCase => crate::utils::casing::to_camel_case(name),
149            NamingConvention::Preserve => name.to_string(),
150        }
151    }
152
153    /// Find a type definition by name.
154    #[must_use]
155    pub fn find_type(&self, name: &str) -> Option<&TypeDefinition> {
156        self.types.iter().find(|t| t.name == name)
157    }
158
159    /// Whether the named type has any policy-gated field
160    /// ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)).
161    ///
162    /// Used by projection paths that do not (yet) run the dynamic field authorizer
163    /// to fail closed conservatively when a gated type could be projected (#423).
164    #[must_use]
165    pub fn type_has_gated_field(&self, type_name: &str) -> bool {
166        self.find_type(type_name).is_some_and(|t| t.fields.iter().any(|f| f.authorize))
167    }
168
169    /// Whether any object type in the schema declares a policy-gated field (#423).
170    ///
171    /// Used by projection paths with a dynamic/unknown result type (Relay `node`,
172    /// federation `_entities`) to fail closed when field-level authorization is in
173    /// use but cannot yet be enforced on that path.
174    #[must_use]
175    pub fn has_any_authorize_field(&self) -> bool {
176        self.types.iter().any(|t| t.fields.iter().any(|f| f.authorize))
177    }
178
179    /// Find an enum definition by name.
180    #[must_use]
181    pub fn find_enum(&self, name: &str) -> Option<&EnumDefinition> {
182        self.enums.iter().find(|e| e.name == name)
183    }
184
185    /// Find an input object definition by name.
186    #[must_use]
187    pub fn find_input_type(&self, name: &str) -> Option<&InputObjectDefinition> {
188        self.input_types.iter().find(|i| i.name == name)
189    }
190
191    /// Find an interface definition by name.
192    #[must_use]
193    pub fn find_interface(&self, name: &str) -> Option<&InterfaceDefinition> {
194        self.interfaces.iter().find(|i| i.name == name)
195    }
196
197    /// Find all types that implement a given interface.
198    #[must_use]
199    pub fn find_implementors(&self, interface_name: &str) -> Vec<&TypeDefinition> {
200        self.types
201            .iter()
202            .filter(|t| t.implements.contains(&interface_name.to_string()))
203            .collect()
204    }
205
206    /// Find a union definition by name.
207    #[must_use]
208    pub fn find_union(&self, name: &str) -> Option<&UnionDefinition> {
209        self.unions.iter().find(|u| u.name == name)
210    }
211
212    /// Find a query definition by name.
213    ///
214    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
215    /// scan for schemas built directly in tests without calling `build_indexes()`.
216    ///
217    /// If the exact name is not found, retries with `to_snake_case(name)` to
218    /// handle camelCase → `snake_case` normalization (e.g. `dnsServers` →
219    /// `dns_servers`). This supports schemas compiled before the SDK camelCase
220    /// migration.
221    #[must_use]
222    pub fn find_query(&self, name: &str) -> Option<&QueryDefinition> {
223        if self.query_index.is_empty() && !self.queries.is_empty() {
224            self.queries.iter().find(|q| q.name == name).or_else(|| {
225                let snake = crate::utils::casing::to_snake_case(name);
226                self.queries.iter().find(|q| q.name == snake)
227            })
228        } else {
229            self.query_index
230                .get(name)
231                .or_else(|| self.query_index.get(&crate::utils::casing::to_snake_case(name)))
232                .map(|&i| &self.queries[i])
233        }
234    }
235
236    /// Find a mutation definition by name.
237    ///
238    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
239    /// scan for schemas built directly in tests without calling `build_indexes()`.
240    ///
241    /// If the exact name is not found, retries with `to_snake_case(name)` to
242    /// handle camelCase → `snake_case` normalization. This supports schemas
243    /// compiled before the SDK camelCase migration.
244    #[must_use]
245    pub fn find_mutation(&self, name: &str) -> Option<&MutationDefinition> {
246        if self.mutation_index.is_empty() && !self.mutations.is_empty() {
247            self.mutations.iter().find(|m| m.name == name).or_else(|| {
248                let snake = crate::utils::casing::to_snake_case(name);
249                self.mutations.iter().find(|m| m.name == snake)
250            })
251        } else {
252            self.mutation_index
253                .get(name)
254                .or_else(|| self.mutation_index.get(&crate::utils::casing::to_snake_case(name)))
255                .map(|&i| &self.mutations[i])
256        }
257    }
258
259    /// Find a subscription definition by name.
260    ///
261    /// Uses the O(1) pre-built index when available; falls back to O(n) linear
262    /// scan for schemas built directly in tests without calling `build_indexes()`.
263    ///
264    /// If the exact name is not found, retries with `to_snake_case(name)` to
265    /// handle camelCase → `snake_case` normalization. This supports schemas
266    /// compiled before the SDK camelCase migration.
267    #[must_use]
268    pub fn find_subscription(&self, name: &str) -> Option<&SubscriptionDefinition> {
269        if self.subscription_index.is_empty() && !self.subscriptions.is_empty() {
270            self.subscriptions.iter().find(|s| s.name == name).or_else(|| {
271                let snake = crate::utils::casing::to_snake_case(name);
272                self.subscriptions.iter().find(|s| s.name == snake)
273            })
274        } else {
275            self.subscription_index
276                .get(name)
277                .or_else(|| self.subscription_index.get(&crate::utils::casing::to_snake_case(name)))
278                .map(|&i| &self.subscriptions[i])
279        }
280    }
281
282    /// Find a custom directive definition by name.
283    #[must_use]
284    pub fn find_directive(&self, name: &str) -> Option<&DirectiveDefinition> {
285        self.directives.iter().find(|d| d.name == name)
286    }
287
288    /// Get total number of operations (queries + mutations + subscriptions).
289    #[must_use]
290    pub const fn operation_count(&self) -> usize {
291        self.queries.len() + self.mutations.len() + self.subscriptions.len()
292    }
293}