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 /// The types a successful mutation returning `return_type` can produce: the non-error
213 /// members of a union, the implementors of an interface, or the type itself.
214 ///
215 /// The one answer to "what entity does this mutation write" — the mutation runner's
216 /// stamp check, the after-mutation dispatcher and the compiler's trigger
217 /// cross-reference all read it, so a synthesized error union (`auto_error_union`)
218 /// cannot rename the entity for one of them and not the others (#1340).
219 #[must_use]
220 pub fn success_types(&self, return_type: &str) -> Vec<String> {
221 if let Some(union) = self.find_union(return_type) {
222 return union
223 .member_types
224 .iter()
225 .filter(|t| self.find_type(t).is_none_or(|td| !td.is_error))
226 .cloned()
227 .collect();
228 }
229 if self.find_interface(return_type).is_some() {
230 return self
231 .types
232 .iter()
233 .filter(|t| t.implements.iter().any(|i| i == return_type))
234 .map(|t| t.name.to_string())
235 .collect();
236 }
237 vec![return_type.to_string()]
238 }
239
240 /// Find a query definition by name.
241 ///
242 /// Uses the O(1) pre-built index when available; falls back to O(n) linear
243 /// scan for schemas built directly in tests without calling `build_indexes()`.
244 ///
245 /// If the exact name is not found, retries with `to_snake_case(name)` to
246 /// handle camelCase → `snake_case` normalization (e.g. `dnsServers` →
247 /// `dns_servers`). This supports schemas compiled before the SDK camelCase
248 /// migration.
249 #[must_use]
250 pub fn find_query(&self, name: &str) -> Option<&QueryDefinition> {
251 if self.query_index.is_empty() && !self.queries.is_empty() {
252 self.queries.iter().find(|q| q.name == name).or_else(|| {
253 let snake = crate::utils::casing::to_snake_case(name);
254 self.queries.iter().find(|q| q.name == snake)
255 })
256 } else {
257 self.query_index
258 .get(name)
259 .or_else(|| self.query_index.get(&crate::utils::casing::to_snake_case(name)))
260 .map(|&i| &self.queries[i])
261 }
262 }
263
264 /// Find a mutation definition by name.
265 ///
266 /// Uses the O(1) pre-built index when available; falls back to O(n) linear
267 /// scan for schemas built directly in tests without calling `build_indexes()`.
268 ///
269 /// If the exact name is not found, retries with `to_snake_case(name)` to
270 /// handle camelCase → `snake_case` normalization. This supports schemas
271 /// compiled before the SDK camelCase migration.
272 #[must_use]
273 pub fn find_mutation(&self, name: &str) -> Option<&MutationDefinition> {
274 if self.mutation_index.is_empty() && !self.mutations.is_empty() {
275 self.mutations.iter().find(|m| m.name == name).or_else(|| {
276 let snake = crate::utils::casing::to_snake_case(name);
277 self.mutations.iter().find(|m| m.name == snake)
278 })
279 } else {
280 self.mutation_index
281 .get(name)
282 .or_else(|| self.mutation_index.get(&crate::utils::casing::to_snake_case(name)))
283 .map(|&i| &self.mutations[i])
284 }
285 }
286
287 /// Find a subscription definition by name.
288 ///
289 /// Uses the O(1) pre-built index when available; falls back to O(n) linear
290 /// scan for schemas built directly in tests without calling `build_indexes()`.
291 ///
292 /// If the exact name is not found, retries with `to_snake_case(name)` to
293 /// handle camelCase → `snake_case` normalization. This supports schemas
294 /// compiled before the SDK camelCase migration.
295 #[must_use]
296 pub fn find_subscription(&self, name: &str) -> Option<&SubscriptionDefinition> {
297 if self.subscription_index.is_empty() && !self.subscriptions.is_empty() {
298 self.subscriptions.iter().find(|s| s.name == name).or_else(|| {
299 let snake = crate::utils::casing::to_snake_case(name);
300 self.subscriptions.iter().find(|s| s.name == snake)
301 })
302 } else {
303 self.subscription_index
304 .get(name)
305 .or_else(|| self.subscription_index.get(&crate::utils::casing::to_snake_case(name)))
306 .map(|&i| &self.subscriptions[i])
307 }
308 }
309
310 /// Find a custom directive definition by name.
311 #[must_use]
312 pub fn find_directive(&self, name: &str) -> Option<&DirectiveDefinition> {
313 self.directives.iter().find(|d| d.name == name)
314 }
315
316 /// Get total number of operations (queries + mutations + subscriptions).
317 #[must_use]
318 pub const fn operation_count(&self) -> usize {
319 self.queries.len() + self.mutations.len() + self.subscriptions.len()
320 }
321}