1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
//! Schema validator - validates IR for correctness.
//!
//! # Validation Rules
//!
//! - Type references are valid
//! - SQL bindings exist
//! - No circular dependencies
//! - Auth rules are valid
//! - Analytics fact table metadata is valid
//! - Aggregate types follow required structure
use super::ir::AuthoringIR;
use crate::{
error::{FraiseQLError, Result},
schema::is_known_scalar,
};
/// Extract the base type name from a GraphQL type string.
///
/// Removes list brackets, non-null markers, and whitespace.
/// Examples:
/// - "String!" -> "String"
/// - "[User]" -> "User"
/// - "[User!]!" -> "User"
/// - "Int" -> "Int"
pub(crate) fn extract_base_type(type_str: &str) -> &str {
let s = type_str.trim();
// Remove list brackets and non-null markers
let s = s.trim_start_matches('[').trim_end_matches(']');
let s = s.trim_end_matches('!').trim_start_matches('!');
// Handle nested cases like "[User!]!"
let s = s.trim_start_matches('[').trim_end_matches(']');
let s = s.trim_end_matches('!');
s.trim()
}
/// Check if a type is valid (either a known scalar or defined type).
fn is_valid_type(base_type: &str, defined_types: &std::collections::HashSet<&str>) -> bool {
is_known_scalar(base_type) || defined_types.contains(base_type)
}
/// Schema validation error produced by [`SchemaValidator`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaValidationError {
/// Error message.
pub message: String,
/// Location in schema.
pub location: String,
}
/// Schema validator.
pub struct SchemaValidator {
// Validator state
}
impl SchemaValidator {
/// Create new validator.
#[must_use]
pub const fn new() -> Self {
Self {}
}
/// Validate IR.
///
/// # Arguments
///
/// * `ir` - Authoring IR to validate
///
/// # Returns
///
/// Validated IR (potentially with transformations)
///
/// # Errors
///
/// Returns error if validation fails.
pub fn validate(&self, ir: AuthoringIR) -> Result<AuthoringIR> {
// Comprehensive type coverage validation for all operation types
// Note: validate_queries() also validates mutations and subscriptions
// See lines 220-260 for full validation logic
self.validate_types(&ir)?;
self.validate_queries(&ir)?;
// Analytics validation
if !ir.fact_tables.is_empty() {
self.validate_fact_tables(&ir)?;
}
// Validate aggregate types (regardless of fact_tables)
// This ensures aggregate types in the schema follow the required structure
self.validate_aggregate_types(&ir)?;
Ok(ir)
}
/// Validate type definitions.
fn validate_types(&self, ir: &AuthoringIR) -> Result<()> {
// Collect all defined type names
let defined_types: std::collections::HashSet<&str> =
ir.types.iter().map(|t| t.name.as_str()).collect();
// Validate each type
for ir_type in &ir.types {
// Validate type name is not empty
if ir_type.name.is_empty() {
return Err(FraiseQLError::Validation {
message: "Type name cannot be empty".to_string(),
path: Some("types".to_string()),
});
}
// Validate field types reference valid types
for field in &ir_type.fields {
let base_type = extract_base_type(&field.field_type);
// Skip validation for list markers and check if type is valid
if !base_type.is_empty() && !is_valid_type(base_type, &defined_types) {
return Err(FraiseQLError::Validation {
message: format!(
"Type '{}' field '{}' references unknown type '{}'",
ir_type.name, field.name, base_type
),
path: Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
});
}
}
}
Ok(())
}
/// Validate query definitions.
fn validate_queries(&self, ir: &AuthoringIR) -> Result<()> {
// Collect all defined type names
let defined_types: std::collections::HashSet<&str> =
ir.types.iter().map(|t| t.name.as_str()).collect();
// Validate each query
for query in &ir.queries {
// Validate query name is not empty
if query.name.is_empty() {
return Err(FraiseQLError::Validation {
message: "Query name cannot be empty".to_string(),
path: Some("queries".to_string()),
});
}
// Validate return type exists
let base_type = extract_base_type(&query.return_type);
if !is_valid_type(base_type, &defined_types) {
return Err(FraiseQLError::Validation {
message: format!(
"Query '{}' returns unknown type '{}'",
query.name, query.return_type
),
path: Some(format!("queries.{}.return_type", query.name)),
});
}
// Validate argument types
for arg in &query.arguments {
let base_type = extract_base_type(&arg.arg_type);
if !is_valid_type(base_type, &defined_types) {
return Err(FraiseQLError::Validation {
message: format!(
"Query '{}' argument '{}' has unknown type '{}'",
query.name, arg.name, arg.arg_type
),
path: Some(format!("queries.{}.arguments.{}", query.name, arg.name)),
});
}
}
}
// Validate mutations
for mutation in &ir.mutations {
if mutation.name.is_empty() {
return Err(FraiseQLError::Validation {
message: "Mutation name cannot be empty".to_string(),
path: Some("mutations".to_string()),
});
}
let base_type = extract_base_type(&mutation.return_type);
if !is_valid_type(base_type, &defined_types) {
return Err(FraiseQLError::Validation {
message: format!(
"Mutation '{}' returns unknown type '{}'",
mutation.name, mutation.return_type
),
path: Some(format!("mutations.{}.return_type", mutation.name)),
});
}
}
// Validate subscriptions
for subscription in &ir.subscriptions {
if subscription.name.is_empty() {
return Err(FraiseQLError::Validation {
message: "Subscription name cannot be empty".to_string(),
path: Some("subscriptions".to_string()),
});
}
let base_type = extract_base_type(&subscription.return_type);
if !is_valid_type(base_type, &defined_types) {
return Err(FraiseQLError::Validation {
message: format!(
"Subscription '{}' returns unknown type '{}'",
subscription.name, subscription.return_type
),
path: Some(format!("subscriptions.{}.return_type", subscription.name)),
});
}
}
Ok(())
}
/// Validate fact table metadata structure.
///
/// Ensures that fact table metadata follows the required structure:
/// - Table name uses `tf_*` prefix
/// - Has at least one measure
fn validate_fact_tables(&self, ir: &AuthoringIR) -> Result<()> {
for (table_name, metadata) in &ir.fact_tables {
// Validate table name follows tf_* pattern
if !table_name.starts_with("tf_") {
return Err(FraiseQLError::Validation {
message: format!("Fact table '{}' must start with 'tf_' prefix", table_name),
path: Some(format!("fact_tables.{}", table_name)),
});
}
if metadata.measures.is_empty() {
return Err(FraiseQLError::Validation {
message: format!("Fact table '{}' must have at least one measure", table_name),
path: Some(format!("fact_tables.{}.measures", table_name)),
});
}
// Validate dimensions name is not empty
if metadata.dimensions.name.is_empty() {
return Err(FraiseQLError::Validation {
message: format!("Fact table '{}' dimensions missing 'name' field", table_name),
path: Some(format!("fact_tables.{}.dimensions", table_name)),
});
}
}
Ok(())
}
/// Validate aggregate types follow required structure.
///
/// Aggregate types must:
/// - Have a `count` field (always available)
/// - Have measure aggregate fields (e.g., `revenue_sum`, `quantity_avg`)
/// - `GroupByInput` types must have Boolean fields
/// - `HavingInput` types must have comparison operator suffixes
fn validate_aggregate_types(&self, ir: &AuthoringIR) -> Result<()> {
// Find aggregate types (those ending with "Aggregate")
for ir_type in &ir.types {
if ir_type.name.ends_with("Aggregate") {
// Validate has count field
let has_count = ir_type.fields.iter().any(|f| f.name == "count");
if !has_count {
return Err(FraiseQLError::Validation {
message: format!(
"Aggregate type '{}' must have a 'count' field",
ir_type.name
),
path: Some(format!("types.{}.fields", ir_type.name)),
});
}
}
// Validate GroupByInput types
if ir_type.name.ends_with("GroupByInput") {
for field in &ir_type.fields {
// All fields must be Boolean type
if field.field_type != "Boolean" && field.field_type != "Boolean!" {
return Err(FraiseQLError::Validation {
message: format!(
"GroupByInput type '{}' field '{}' must be Boolean, got '{}'",
ir_type.name, field.name, field.field_type
),
path: Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
});
}
}
}
// Validate HavingInput types
if ir_type.name.ends_with("HavingInput") {
for field in &ir_type.fields {
// Field names must have operator suffixes (_eq, _gt, _gte, _lt, _lte)
let valid_suffixes = ["_eq", "_neq", "_gt", "_gte", "_lt", "_lte"];
let has_valid_suffix = valid_suffixes.iter().any(|s| field.name.ends_with(s));
if !has_valid_suffix {
return Err(FraiseQLError::Validation {
message: format!(
"HavingInput type '{}' field '{}' must have operator suffix (_eq, _neq, _gt, _gte, _lt, _lte)",
ir_type.name, field.name
),
path: Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
});
}
}
}
}
Ok(())
}
}
impl Default for SchemaValidator {
fn default() -> Self {
Self::new()
}
}