fraiseql_core/compiler/window_allowlist.rs
1//! Schema-based allowlist for window query identifiers.
2//!
3//! Provides defence-in-depth on top of the character-level validation in
4//! `window_functions/planner.rs`: every identifier used in a window query's
5//! PARTITION BY, ORDER BY, or SELECT clauses is checked against the set of
6//! fields declared in the compiled schema.
7//!
8//! When the allowlist is empty (i.e. the schema declares no fact-table metadata
9//! for this type) the validation is skipped, matching the behaviour of
10//! `compiler/aggregation.rs` when `metadata.dimensions.paths` is empty.
11
12use std::collections::HashSet;
13
14use crate::{
15 compiler::fact_table::FactTableMetadata,
16 error::{FraiseQLError, Result},
17};
18
19/// Validated allowlist of identifiers permitted in window queries for a given type.
20///
21/// Built at request-planning time from the compiled schema's `FactTableMetadata`.
22/// An empty allowlist means "no schema constraints are declared; character-level
23/// validation still applies".
24#[derive(Debug, Clone, Default)]
25pub struct WindowAllowlist {
26 /// All valid field expressions.
27 ///
28 /// Contains:
29 /// - measure column names (e.g. `"revenue"`)
30 /// - denormalised filter column names (e.g. `"occurred_at"`)
31 /// - dimension JSONB path expressions (e.g. `"dimensions->>'category'"`)
32 fields: HashSet<String>,
33}
34
35impl WindowAllowlist {
36 /// Build an allowlist from compiled fact-table metadata.
37 #[must_use]
38 pub fn from_metadata(metadata: &FactTableMetadata) -> Self {
39 let mut fields = HashSet::new();
40 for m in &metadata.measures {
41 fields.insert(m.name.clone());
42 }
43 for f in &metadata.denormalized_filters {
44 fields.insert(f.name.clone());
45 }
46 for p in &metadata.dimensions.paths {
47 // Store both the short name ("category") and the full JSONB expression
48 // ("dimensions->>'category'") so callers can use either form.
49 fields.insert(p.name.clone());
50 fields.insert(p.json_path.clone());
51 }
52 Self { fields }
53 }
54
55 /// Returns `true` if no schema constraints are declared.
56 ///
57 /// An empty allowlist does not block any identifier; character-level
58 /// validation in the planner still applies.
59 #[must_use]
60 pub fn is_empty(&self) -> bool {
61 self.fields.is_empty()
62 }
63
64 /// Validate that `identifier` is in the allowlist.
65 ///
66 /// When the allowlist is empty (no schema constraints), this is a no-op.
67 ///
68 /// # Errors
69 ///
70 /// Returns `FraiseQLError::Validation` if the identifier is not in the
71 /// allowlist and the allowlist is non-empty.
72 pub fn validate(&self, identifier: &str, context: &str) -> Result<()> {
73 if self.fields.is_empty() || self.fields.contains(identifier) {
74 Ok(())
75 } else {
76 Err(FraiseQLError::Validation {
77 message: format!(
78 "Field '{identifier}' is not a known {context} field for this window query. \
79 Only fields declared in the compiled schema are permitted."
80 ),
81 path: None,
82 })
83 }
84 }
85}