Skip to main content

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}