fraiseql_core/schema/compiled/schema_serde.rs
1//! Serialization, deserialization, and integrity checking for [`CompiledSchema`].
2
3use sha2::{Digest, Sha256};
4use tracing::{info, warn};
5
6use super::schema::CompiledSchema;
7use crate::error::FraiseQLError;
8
9/// Recursively sort all JSON object keys to produce a canonical representation.
10///
11/// This guarantees deterministic serialization regardless of `HashMap` iteration
12/// order or `serde_json` feature flags (`preserve_order`). Used by both the CLI
13/// (hash embed) and `from_json` (hash verify) to ensure round-trip consistency.
14///
15/// # Example
16///
17/// ```
18/// use fraiseql_core::schema::canonicalize_json;
19///
20/// let v: serde_json::Value = serde_json::from_str(r#"{"b":1,"a":2}"#).unwrap();
21/// let c = canonicalize_json(&v);
22/// assert_eq!(serde_json::to_string(&c).unwrap(), r#"{"a":2,"b":1}"#);
23/// ```
24#[must_use]
25pub fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
26 match value {
27 serde_json::Value::Object(map) => {
28 let mut sorted = serde_json::Map::new();
29 let mut keys: Vec<&String> = map.keys().collect();
30 keys.sort();
31 for key in keys {
32 sorted.insert(key.clone(), canonicalize_json(&map[key]));
33 }
34 serde_json::Value::Object(sorted)
35 },
36 serde_json::Value::Array(arr) => {
37 serde_json::Value::Array(arr.iter().map(canonicalize_json).collect())
38 },
39 other => other.clone(),
40 }
41}
42
43/// The content hash of a compiled schema, as a hex string.
44///
45/// This is the **one** place the digest is defined. The CLI embeds it, `from_json`
46/// re-derives it to verify, [`CompiledSchema::content_hash`] uses it as the cache
47/// version, and the integrity test drives it — because three hand-copies of
48/// "canonicalize, pretty-print, SHA-256, take 16 bytes" is three chances to
49/// disagree, and they did: the integrity test hashed the *uncanonicalized* value
50/// and passed only because `serde_json::Map` happened to be a sorted `BTreeMap`
51/// in the build it ran in (#899).
52///
53/// `value` must not contain the `_content_hash` key: a schema cannot hash its own
54/// hash.
55#[must_use]
56pub fn content_hash_of(value: &serde_json::Value) -> String {
57 let canonical = canonicalize_json(value);
58 // `to_string_pretty` on a canonicalized value cannot fail — every node is a
59 // plain JSON value with no non-string map keys.
60 let rendered = serde_json::to_string_pretty(&canonical).unwrap_or_default();
61 let digest = Sha256::digest(rendered.as_bytes());
62 hex::encode(&digest[..16]) // 32 hex chars — sufficient collision resistance
63}
64
65impl CompiledSchema {
66 /// Deserialize from JSON string.
67 ///
68 /// This is the primary way to create a schema from any authoring language.
69 /// The authoring language emits `schema.json`; `fraiseql-cli compile` produces
70 /// `schema.compiled.json`; Rust deserializes and owns the result.
71 ///
72 /// # Integrity Checking
73 ///
74 /// `fraiseql-cli compile` embeds a `_content_hash` field (SHA-256 of the compiled JSON
75 /// body, first 16 bytes as lowercase hex) in the compiled output. This function
76 /// extracts that field, recomputes the hash over the remaining JSON, and compares.
77 ///
78 /// - `strict_integrity = true`: missing or mismatched hash returns `Err`.
79 /// - `strict_integrity = false`: missing hash logs a warning; mismatch logs a warning but
80 /// proceeds (backwards compatibility for schemas compiled without `_content_hash`).
81 ///
82 /// # Errors
83 ///
84 /// Returns error if JSON is malformed or doesn't match schema structure.
85 ///
86 /// # Example
87 ///
88 /// ```
89 /// use fraiseql_core::schema::CompiledSchema;
90 ///
91 /// let json = r#"{"types": [], "queries": [], "mutations": [], "subscriptions": []}"#;
92 /// let schema = CompiledSchema::from_json(json, false).unwrap();
93 /// ```
94 pub fn from_json(
95 json: &str,
96 strict_integrity: bool,
97 ) -> std::result::Result<Self, FraiseQLError> {
98 let serde_err = |e: serde_json::Error| FraiseQLError::Parse {
99 message: format!("Schema JSON parse error: {e}"),
100 location: String::new(),
101 };
102 // Typed deserialization goes through `serde_path_to_error` so a refused
103 // schema names the offending section ("security.rate_limiting.…"), not
104 // just a line/column into a machine-formatted file — the #778 property,
105 // kept now that malformed sections are refused at load (#977).
106 let path_err = |e: serde_path_to_error::Error<serde_json::Error>| FraiseQLError::Parse {
107 message: format!("Schema JSON parse error at `{}`: {}", e.path(), e.inner()),
108 location: e.path().to_string(),
109 };
110
111 let mut value: serde_json::Value = serde_json::from_str(json).map_err(serde_err)?;
112
113 let obj = value.as_object_mut().ok_or_else(|| FraiseQLError::Validation {
114 message: "Schema JSON must be an object".to_string(),
115 path: None,
116 })?;
117
118 // Extract and remove _content_hash
119 let expected_hash = if let Some(hash_val) = obj.remove("_content_hash") {
120 if let Some(hash_str) = hash_val.as_str() {
121 Some(hash_str.to_string())
122 } else {
123 return Err(FraiseQLError::Validation {
124 message: "_content_hash must be a string".to_string(),
125 path: None,
126 });
127 }
128 } else if strict_integrity {
129 return Err(FraiseQLError::Validation {
130 message: "Schema integrity check failed: missing _content_hash field. Enable strict_schema_integrity=false for backwards compatibility.".to_string(),
131 path: None,
132 });
133 } else {
134 warn!(
135 "Schema integrity check skipped: no _content_hash field present. Consider recompiling with a newer CLI for integrity verification."
136 );
137 // No hash, parse directly from original
138 let de = &mut serde_json::Deserializer::from_str(json);
139 let mut schema: Self = serde_path_to_error::deserialize(de).map_err(path_err)?;
140 schema.build_indexes();
141 schema.finish_load()?;
142 return Ok(schema);
143 };
144
145 // Canonicalize and serialize deterministically (sorted keys at all levels)
146 let computed_hash = content_hash_of(&value);
147
148 if let Some(expected) = expected_hash {
149 if expected != computed_hash {
150 if strict_integrity {
151 return Err(FraiseQLError::Validation {
152 message: format!(
153 "Schema integrity check failed: hash mismatch (expected {expected}, got {computed_hash})"
154 ),
155 path: None,
156 });
157 }
158 warn!(
159 "Schema integrity check: hash mismatch (expected {expected}, got {computed_hash}). Proceeding because strict_integrity is disabled."
160 );
161 } else {
162 info!("Schema integrity verified: hash matches");
163 }
164 }
165
166 // Now deserialize the schema from the remaining JSON (the parsed value
167 // with `_content_hash` already removed).
168 let mut schema: Self = serde_path_to_error::deserialize(value).map_err(path_err)?;
169 schema.build_indexes();
170 schema.finish_load()?;
171 Ok(schema)
172 }
173
174 /// Normalization every load must perform, and the checks that must hold after it.
175 ///
176 /// Lives here rather than in `build_indexes` because it can fail, and because
177 /// both `from_json` branches must run it — the hashed and the unhashed one. A
178 /// normalization step performed on one load path and not its sibling is the
179 /// shape that produced #748 and #812 in other subsystems.
180 ///
181 /// Today it refuses a schema that declares a name twice (#1265), lowers each
182 /// type's `requires_role` onto the operations that return it (#677) and refuses a
183 /// schema whose role declarations the runtime cannot honour, then refuses one whose
184 /// type-level and query-level scoping declarations contradict each other (#1142),
185 /// one whose subscription row-visibility policy the delivery path cannot honour
186 /// (#596/#1265), one whose subscription filter names an argument it does not
187 /// declare (#1262), one declaring `requires_scope` with no role able to grant it
188 /// (ruling Y 7), and one declaring a relationship no embed can follow (#1266).
189 ///
190 /// # Errors
191 ///
192 /// Returns [`FraiseQLError::Validation`] when a role-gated type is declared in a
193 /// shape no execution path can enforce — see
194 /// [`CompiledSchema::type_role_violations`] — when a type and its backing query
195 /// scope the same column from different sources, see
196 /// [`CompiledSchema::type_inject_violations`], when a federation entity's `@key`
197 /// cannot be served, see [`CompiledSchema::federation_key_problems`], or when a
198 /// relationship names a target, a join column or a list query the embed executor
199 /// cannot resolve, see [`CompiledSchema::relationship_violations`].
200 /// Every field whose `hierarchy` names no `[hierarchies.<name>]` entry, and every
201 /// fact-table filter whose `hierarchy` names none or sits on a column that is not an
202 /// ltree path.
203 fn hierarchy_link_violations(&self) -> Vec<String> {
204 let declared = self.hierarchies_config.as_ref();
205 let mut violations: Vec<String> = self
206 .types
207 .iter()
208 .flat_map(|t| t.fields.iter().map(move |f| (t, f)))
209 .filter_map(|(t, f)| {
210 let name = f.hierarchy.as_deref()?;
211 if declared.is_some_and(|h| h.contains_key(name)) {
212 return None;
213 }
214 Some(format!(
215 "field `{}.{}` links hierarchy `{name}`, which no `[hierarchies.{name}]` \
216 table declares (table + path_column)",
217 t.name, f.name
218 ))
219 })
220 .collect();
221 // A fact table's path column resolves node ids through its hierarchy too (#1498).
222 for (table, metadata) in &self.fact_tables {
223 for filter in &metadata.denormalized_filters {
224 let Some(name) = filter.hierarchy.as_deref() else {
225 continue;
226 };
227 if filter.sql_type != crate::compiler::fact_table::SqlType::Ltree {
228 violations.push(format!(
229 "fact table filter `{table}.{}` links hierarchy `{name}` but is not an \
230 ltree path column (sql_type {:?})",
231 filter.name, filter.sql_type
232 ));
233 } else if !declared.is_some_and(|h| h.contains_key(name)) {
234 violations.push(format!(
235 "fact table filter `{table}.{}` links hierarchy `{name}`, which no \
236 `[hierarchies.{name}]` table declares (table + path_column)",
237 filter.name
238 ));
239 }
240 }
241 }
242 violations.sort();
243 violations
244 }
245
246 fn finish_load(&mut self) -> std::result::Result<(), FraiseQLError> {
247 // First: a name declared twice makes every later check ambiguous. `build_indexes`
248 // keys by name, so the second definition silently shadows the first and the
249 // schema behaves as something nobody wrote (#1265).
250 let violations = self.duplicate_name_violations();
251 if !violations.is_empty() {
252 return Err(FraiseQLError::Validation {
253 message: format!(
254 "schema declares a name twice:\n - {}",
255 violations.join("\n - ")
256 ),
257 path: Some("schema.names".to_string()),
258 });
259 }
260 self.propagate_type_roles();
261 let violations = self.type_role_violations();
262 if !violations.is_empty() {
263 return Err(FraiseQLError::Validation {
264 message: format!(
265 "type-level `requires_role` cannot be enforced as declared:\n - {}",
266 violations.join("\n - ")
267 ),
268 path: Some("security.requires_role".to_string()),
269 });
270 }
271 // #1396: a field's `hierarchy` names the table `descendantOfId` / `ancestorOfId`
272 // resolve node ids against. One naming nothing would make those operators fail on
273 // every request, so the artifact is refused here, where both compile workflows and
274 // a hand-edited artifact pass.
275 let violations = self.hierarchy_link_violations();
276 if !violations.is_empty() {
277 return Err(FraiseQLError::Validation {
278 message: format!(
279 "a field links a hierarchy that is not declared:\n - {}",
280 violations.join("\n - ")
281 ),
282 path: Some("hierarchies_config".to_string()),
283 });
284 }
285 let violations = self.type_inject_violations();
286 if !violations.is_empty() {
287 return Err(FraiseQLError::Validation {
288 message: format!(
289 "type-level `inject_params` cannot be enforced as declared:\n - {}",
290 violations.join("\n - ")
291 ),
292 path: Some("security.inject_params".to_string()),
293 });
294 }
295 let violations = self.subscription_policy_violations();
296 if !violations.is_empty() {
297 return Err(FraiseQLError::Validation {
298 message: format!(
299 "subscription policies cannot be applied as declared:\n - {}",
300 violations.join("\n - ")
301 ),
302 path: Some("types.subscription_policy".to_string()),
303 });
304 }
305 let violations = self.subscription_filter_violations();
306 if !violations.is_empty() {
307 return Err(FraiseQLError::Validation {
308 message: format!(
309 "subscription filters cannot be applied as declared:\n - {}",
310 violations.join("\n - ")
311 ),
312 path: Some("subscriptions.filter".to_string()),
313 });
314 }
315 let violations = self.scope_violations();
316 if !violations.is_empty() {
317 return Err(FraiseQLError::Validation {
318 message: format!(
319 "field-level `requires_scope` cannot be enforced as declared:\n - {}",
320 violations.join("\n - ")
321 ),
322 path: Some("security.requires_scope".to_string()),
323 });
324 }
325 let violations = self.fact_table_link_violations();
326 if !violations.is_empty() {
327 return Err(FraiseQLError::Validation {
328 message: format!(
329 "fact tables cannot be read as their types:\n - {}",
330 violations.join("\n - ")
331 ),
332 path: Some("fact_tables.type_name".to_string()),
333 });
334 }
335 let violations = self.fact_table_mapping_violations();
336 if !violations.is_empty() {
337 return Err(FraiseQLError::Validation {
338 message: format!(
339 "fact table dimensions are declared twice:\n - {}",
340 violations.join("\n - ")
341 ),
342 path: Some("fact_tables.native_dimension_mapping".to_string()),
343 });
344 }
345 let violations = self.federation_key_violations();
346 if !violations.is_empty() {
347 return Err(FraiseQLError::Validation {
348 message: format!(
349 "federation entities cannot be served as declared:\n - {}",
350 violations.join("\n - ")
351 ),
352 path: Some("federation.entities".to_string()),
353 });
354 }
355 let violations = self.relationship_violations();
356 if !violations.is_empty() {
357 return Err(FraiseQLError::Validation {
358 message: format!(
359 "relationships cannot be followed as declared:\n - {}",
360 violations.join("\n - ")
361 ),
362 path: Some("types.relationships".to_string()),
363 });
364 }
365 Ok(())
366 }
367
368 /// Types whose declared subscription row-visibility policy the delivery path cannot
369 /// honour (#596).
370 ///
371 /// This check lived in `CompiledSchema::validate()`, which had **no caller outside
372 /// tests** — so #596's own comment calling it "a load-time error, not a silent
373 /// deliver-all at subscribe time" described something that never ran, and
374 /// `SubscriptionPolicy::validate` had exactly one caller: the unreachable one
375 /// (#1265).
376 ///
377 /// It matters precisely here, because `subscription_policy` has no authoring
378 /// producer at all — the CLI hardcodes `None` at every construction site, the
379 /// intermediate schema has no field for it and no SDK emits one. The only way a
380 /// policy reaches a deployment is a hand-written compiled schema, which is exactly
381 /// the input this path accepts.
382 fn subscription_policy_violations(&self) -> Vec<String> {
383 self.types
384 .iter()
385 .filter_map(|type_def| {
386 type_def
387 .subscription_policy
388 .as_ref()
389 .and_then(|policy| policy.validate().err())
390 .map(|e| format!("Type '{}': {e}", type_def.name))
391 })
392 .collect()
393 }
394
395 /// Types, queries or mutations declared under a name already taken (#1265).
396 ///
397 /// `build_indexes` keys each collection by name, so a duplicate does not conflict —
398 /// it *shadows*, and the schema serves whichever definition indexed last while the
399 /// document plainly contains both. `fraiseql compile` refuses to emit one
400 /// (`SchemaValidator::validate` checks the intermediate schema), which leaves the
401 /// hand-edited artifact.
402 ///
403 /// ⚠ `CompiledSchema::validate()` also checked that every query and mutation return
404 /// type resolved, and that check is **not** carried over, because it was wrong: it
405 /// resolved against `self.types` plus ten builtin scalar names, while the schema
406 /// holds `enums`, `interfaces` and `unions` in separate collections. Measured before
407 /// its removal, it reported `Query 'orderStatus' references undefined type
408 /// 'OrderStatus'` for a valid enum-returning query — so moving it onto this path
409 /// would have refused, at boot, every schema with one. Nobody noticed in the years
410 /// it existed, because nothing ran it. `SchemaValidator::validate` covers return
411 /// types on the compile path, correctly. See
412 /// `a_query_returning_an_enum_or_union_still_loads`.
413 fn duplicate_name_violations(&self) -> Vec<String> {
414 fn duplicates<'a>(kind: &str, names: impl Iterator<Item = &'a str>, out: &mut Vec<String>) {
415 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
416 for name in names {
417 if !seen.insert(name) {
418 out.push(format!("Duplicate {kind} name: {name}"));
419 }
420 }
421 }
422
423 let mut violations = Vec::new();
424 duplicates("type", self.types.iter().map(|t| t.name.as_str()), &mut violations);
425 duplicates("query", self.queries.iter().map(|q| q.name.as_str()), &mut violations);
426 duplicates("mutation", self.mutations.iter().map(|m| m.name.as_str()), &mut violations);
427 violations
428 }
429
430 /// Subscriptions whose filter names an argument they do not declare (#1262).
431 ///
432 /// `fraiseql compile` refuses to emit such a document, which leaves the hand-edited
433 /// artifact — the case a compile-time check cannot reach, and the reason this is
434 /// checked again on the load path every entry point shares.
435 fn subscription_filter_violations(&self) -> Vec<String> {
436 self.subscriptions
437 .iter()
438 .filter_map(crate::schema::SubscriptionDefinition::filter_violation)
439 .collect()
440 }
441
442 /// Serialize to JSON string.
443 ///
444 /// # Errors
445 ///
446 /// Returns error if serialization fails (should not happen for valid schema).
447 pub fn to_json(&self) -> Result<String, serde_json::Error> {
448 serde_json::to_string(self)
449 }
450
451 /// Serialize to pretty JSON string (for debugging/config files).
452 ///
453 /// # Errors
454 ///
455 /// Returns error if serialization fails.
456 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
457 serde_json::to_string_pretty(self)
458 }
459
460 /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
461 ///
462 /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
463 /// cache invalidation on any schema change, regardless of whether the package
464 /// version was bumped.
465 ///
466 /// Two schemas that differ by even one field will produce different hashes.
467 /// The same schema serialised twice always produces the same hash (stable).
468 ///
469 /// # Panics
470 ///
471 /// Does not panic — `CompiledSchema` always serialises to valid JSON.
472 ///
473 /// # Example
474 ///
475 /// ```
476 /// use fraiseql_core::schema::CompiledSchema;
477 ///
478 /// let schema = CompiledSchema::default();
479 /// let hash = schema.content_hash();
480 /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
481 /// ```
482 #[must_use]
483 pub fn content_hash(&self) -> String {
484 let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
485 let value: serde_json::Value =
486 serde_json::from_str(&json).expect("just serialised — always valid JSON");
487 content_hash_of(&value)
488 }
489}