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`], or when a relationship names a
197    /// target, a join column or a list query the embed executor cannot resolve, see
198    /// [`CompiledSchema::relationship_violations`].
199    fn finish_load(&mut self) -> std::result::Result<(), FraiseQLError> {
200        // First: a name declared twice makes every later check ambiguous. `build_indexes`
201        // keys by name, so the second definition silently shadows the first and the
202        // schema behaves as something nobody wrote (#1265).
203        let violations = self.duplicate_name_violations();
204        if !violations.is_empty() {
205            return Err(FraiseQLError::Validation {
206                message: format!(
207                    "schema declares a name twice:\n  - {}",
208                    violations.join("\n  - ")
209                ),
210                path:    Some("schema.names".to_string()),
211            });
212        }
213        self.propagate_type_roles();
214        let violations = self.type_role_violations();
215        if !violations.is_empty() {
216            return Err(FraiseQLError::Validation {
217                message: format!(
218                    "type-level `requires_role` cannot be enforced as declared:\n  - {}",
219                    violations.join("\n  - ")
220                ),
221                path:    Some("security.requires_role".to_string()),
222            });
223        }
224        let violations = self.type_inject_violations();
225        if !violations.is_empty() {
226            return Err(FraiseQLError::Validation {
227                message: format!(
228                    "type-level `inject_params` cannot be enforced as declared:\n  - {}",
229                    violations.join("\n  - ")
230                ),
231                path:    Some("security.inject_params".to_string()),
232            });
233        }
234        let violations = self.subscription_policy_violations();
235        if !violations.is_empty() {
236            return Err(FraiseQLError::Validation {
237                message: format!(
238                    "subscription policies cannot be applied as declared:\n  - {}",
239                    violations.join("\n  - ")
240                ),
241                path:    Some("types.subscription_policy".to_string()),
242            });
243        }
244        let violations = self.subscription_filter_violations();
245        if !violations.is_empty() {
246            return Err(FraiseQLError::Validation {
247                message: format!(
248                    "subscription filters cannot be applied as declared:\n  - {}",
249                    violations.join("\n  - ")
250                ),
251                path:    Some("subscriptions.filter".to_string()),
252            });
253        }
254        let violations = self.scope_violations();
255        if !violations.is_empty() {
256            return Err(FraiseQLError::Validation {
257                message: format!(
258                    "field-level `requires_scope` cannot be enforced as declared:\n  - {}",
259                    violations.join("\n  - ")
260                ),
261                path:    Some("security.requires_scope".to_string()),
262            });
263        }
264        let violations = self.fact_table_link_violations();
265        if !violations.is_empty() {
266            return Err(FraiseQLError::Validation {
267                message: format!(
268                    "fact tables cannot be read as their types:\n  - {}",
269                    violations.join("\n  - ")
270                ),
271                path:    Some("fact_tables.type_name".to_string()),
272            });
273        }
274        let violations = self.relationship_violations();
275        if !violations.is_empty() {
276            return Err(FraiseQLError::Validation {
277                message: format!(
278                    "relationships cannot be followed as declared:\n  - {}",
279                    violations.join("\n  - ")
280                ),
281                path:    Some("types.relationships".to_string()),
282            });
283        }
284        Ok(())
285    }
286
287    /// Types whose declared subscription row-visibility policy the delivery path cannot
288    /// honour (#596).
289    ///
290    /// This check lived in `CompiledSchema::validate()`, which had **no caller outside
291    /// tests** — so #596's own comment calling it "a load-time error, not a silent
292    /// deliver-all at subscribe time" described something that never ran, and
293    /// `SubscriptionPolicy::validate` had exactly one caller: the unreachable one
294    /// (#1265).
295    ///
296    /// It matters precisely here, because `subscription_policy` has no authoring
297    /// producer at all — the CLI hardcodes `None` at every construction site, the
298    /// intermediate schema has no field for it and no SDK emits one. The only way a
299    /// policy reaches a deployment is a hand-written compiled schema, which is exactly
300    /// the input this path accepts.
301    fn subscription_policy_violations(&self) -> Vec<String> {
302        self.types
303            .iter()
304            .filter_map(|type_def| {
305                type_def
306                    .subscription_policy
307                    .as_ref()
308                    .and_then(|policy| policy.validate().err())
309                    .map(|e| format!("Type '{}': {e}", type_def.name))
310            })
311            .collect()
312    }
313
314    /// Types, queries or mutations declared under a name already taken (#1265).
315    ///
316    /// `build_indexes` keys each collection by name, so a duplicate does not conflict —
317    /// it *shadows*, and the schema serves whichever definition indexed last while the
318    /// document plainly contains both. `fraiseql compile` refuses to emit one
319    /// (`SchemaValidator::validate` checks the intermediate schema), which leaves the
320    /// hand-edited artifact.
321    ///
322    /// ⚠ `CompiledSchema::validate()` also checked that every query and mutation return
323    /// type resolved, and that check is **not** carried over, because it was wrong: it
324    /// resolved against `self.types` plus ten builtin scalar names, while the schema
325    /// holds `enums`, `interfaces` and `unions` in separate collections. Measured before
326    /// its removal, it reported `Query 'orderStatus' references undefined type
327    /// 'OrderStatus'` for a valid enum-returning query — so moving it onto this path
328    /// would have refused, at boot, every schema with one. Nobody noticed in the years
329    /// it existed, because nothing ran it. `SchemaValidator::validate` covers return
330    /// types on the compile path, correctly. See
331    /// `a_query_returning_an_enum_or_union_still_loads`.
332    fn duplicate_name_violations(&self) -> Vec<String> {
333        fn duplicates<'a>(kind: &str, names: impl Iterator<Item = &'a str>, out: &mut Vec<String>) {
334            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
335            for name in names {
336                if !seen.insert(name) {
337                    out.push(format!("Duplicate {kind} name: {name}"));
338                }
339            }
340        }
341
342        let mut violations = Vec::new();
343        duplicates("type", self.types.iter().map(|t| t.name.as_str()), &mut violations);
344        duplicates("query", self.queries.iter().map(|q| q.name.as_str()), &mut violations);
345        duplicates("mutation", self.mutations.iter().map(|m| m.name.as_str()), &mut violations);
346        violations
347    }
348
349    /// Subscriptions whose filter names an argument they do not declare (#1262).
350    ///
351    /// `fraiseql compile` refuses to emit such a document, which leaves the hand-edited
352    /// artifact — the case a compile-time check cannot reach, and the reason this is
353    /// checked again on the load path every entry point shares.
354    fn subscription_filter_violations(&self) -> Vec<String> {
355        self.subscriptions
356            .iter()
357            .filter_map(crate::schema::SubscriptionDefinition::filter_violation)
358            .collect()
359    }
360
361    /// Serialize to JSON string.
362    ///
363    /// # Errors
364    ///
365    /// Returns error if serialization fails (should not happen for valid schema).
366    pub fn to_json(&self) -> Result<String, serde_json::Error> {
367        serde_json::to_string(self)
368    }
369
370    /// Serialize to pretty JSON string (for debugging/config files).
371    ///
372    /// # Errors
373    ///
374    /// Returns error if serialization fails.
375    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
376        serde_json::to_string_pretty(self)
377    }
378
379    /// Returns a 32-character hex SHA-256 content hash of this schema's canonical JSON.
380    ///
381    /// Use as `schema_version` when constructing `CachedDatabaseAdapter` to guarantee
382    /// cache invalidation on any schema change, regardless of whether the package
383    /// version was bumped.
384    ///
385    /// Two schemas that differ by even one field will produce different hashes.
386    /// The same schema serialised twice always produces the same hash (stable).
387    ///
388    /// # Panics
389    ///
390    /// Does not panic — `CompiledSchema` always serialises to valid JSON.
391    ///
392    /// # Example
393    ///
394    /// ```
395    /// use fraiseql_core::schema::CompiledSchema;
396    ///
397    /// let schema = CompiledSchema::default();
398    /// let hash = schema.content_hash();
399    /// assert_eq!(hash.len(), 32); // 16 bytes → 32 hex chars
400    /// ```
401    #[must_use]
402    pub fn content_hash(&self) -> String {
403        let json = self.to_json().expect("CompiledSchema always serialises — BUG if this fails");
404        let value: serde_json::Value =
405            serde_json::from_str(&json).expect("just serialised — always valid JSON");
406        content_hash_of(&value)
407    }
408}