Skip to main content

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}