Skip to main content

fraiseql_cli/schema/
optimizer.rs

1//! Schema Optimizer
2//!
3//! Analyzes compiled schemas and adds SQL generation hints for runtime optimization.
4//! This runs during compilation to precompute optimization strategies.
5
6use anyhow::Result;
7use fraiseql_core::schema::{CompiledSchema, QueryDefinition, SqlProjectionHint, TypeDefinition};
8use tracing::{debug, info};
9
10/// Schema optimizer that analyzes queries and adds SQL hints
11pub struct SchemaOptimizer;
12
13impl SchemaOptimizer {
14    /// Optimize a compiled schema
15    ///
16    /// This analyzes queries and adds optimization hints like:
17    /// - Index suggestions
18    /// - Join order hints
19    /// - Projection optimization
20    /// - Predicate pushdown opportunities
21    pub fn optimize(schema: &mut CompiledSchema) -> Result<OptimizationReport> {
22        info!("Optimizing compiled schema");
23
24        let mut report = OptimizationReport::default();
25
26        // Analyze queries for optimization opportunities
27        for query in &schema.queries {
28            Self::analyze_query(query, &mut report);
29        }
30
31        // Analyze types for field access patterns and SQL projection opportunities
32        Self::analyze_types(schema, &mut report);
33
34        // Detect and apply SQL projection hints to types that would benefit
35        Self::apply_sql_projection_hints(schema, &mut report);
36
37        info!("Schema optimization complete: {} hints generated", report.total_hints());
38
39        Ok(report)
40    }
41
42    /// Analyze a single query for optimization opportunities
43    fn analyze_query(query: &QueryDefinition, report: &mut OptimizationReport) {
44        debug!("Analyzing query: {}", query.name);
45
46        // Check if query would benefit from indexes
47        if query.returns_list && !query.arguments.is_empty() {
48            report.index_hints.push(IndexHint {
49                query_name:        query.name.clone(),
50                reason:            "List query with arguments benefits from index".to_string(),
51                suggested_columns: query.arguments.iter().map(|arg| arg.name.clone()).collect(),
52            });
53        }
54
55        // Check for auto-params that suggest filtering
56        if query.auto_params.has_where {
57            report.optimization_notes.push(format!(
58                "Query '{}' supports WHERE filtering - ensure denormalized filter columns exist",
59                query.name
60            ));
61        }
62
63        // Check for pagination
64        if query.auto_params.has_limit || query.auto_params.has_offset {
65            report.optimization_notes.push(format!(
66                "Query '{}' supports pagination - consider adding ORDER BY for deterministic results",
67                query.name
68            ));
69        }
70    }
71
72    /// Analyze types for field access patterns
73    fn analyze_types(schema: &CompiledSchema, report: &mut OptimizationReport) {
74        for type_def in &schema.types {
75            // Check for large number of fields (potential over-fetching)
76            if type_def.fields.len() > 20 {
77                report.optimization_notes.push(format!(
78                    "Type '{}' has {} fields - consider field selection optimization",
79                    type_def.name,
80                    type_def.fields.len()
81                ));
82            }
83
84            // Check for JSONB columns
85            if !type_def.jsonb_column.is_empty() {
86                report.optimization_notes.push(format!(
87                    "Type '{}' uses JSONB column '{}' - ensure GIN index exists for performance",
88                    type_def.name, type_def.jsonb_column
89                ));
90            }
91        }
92    }
93
94    /// Detect and apply SQL projection hints to types that would benefit from SQL-level field
95    /// projection.
96    ///
97    /// SQL projection optimization works by filtering JSONB fields at the database level,
98    /// reducing network payload and JSON deserialization overhead.
99    ///
100    /// Detection heuristics:
101    /// - Type must have a JSONB column
102    /// - Type should have sufficient fields (>10) or estimated large payload (>1KB)
103    /// - PostgreSQL benefit: 95% payload reduction, 37% latency improvement
104    fn apply_sql_projection_hints(schema: &mut CompiledSchema, report: &mut OptimizationReport) {
105        for type_def in &mut schema.types {
106            if Self::should_use_projection(type_def) {
107                let hint = Self::create_projection_hint(type_def);
108
109                debug!(
110                    "Type '{}' qualifies for SQL projection: {} bytes saved ({:.0}%)",
111                    type_def.name,
112                    Self::estimate_payload_savings(type_def),
113                    hint.estimated_reduction_percent
114                );
115
116                type_def.sql_projection_hint = Some(hint);
117                report.projection_hints.push(ProjectionHint {
118                    type_name:                   type_def.name.clone(),
119                    field_count:                 type_def.fields.len(),
120                    estimated_reduction_percent: type_def
121                        .sql_projection_hint
122                        .as_ref()
123                        .map_or(0, |h| h.estimated_reduction_percent),
124                });
125            }
126        }
127    }
128
129    /// Determine if a type should use SQL projection optimization.
130    ///
131    /// A type qualifies for SQL projection if:
132    /// 1. It has a JSONB column (`store_format` == "jsonb")
133    /// 2. It has sufficient fields (>10) OR estimated large payload (>1KB)
134    ///
135    /// Rationale: SQL projection's benefit (reducing JSONB payload) is most valuable
136    /// for types with many fields or large payloads. Small types don't benefit enough
137    /// to justify the SQL generation overhead.
138    fn should_use_projection(type_def: &TypeDefinition) -> bool {
139        // Condition 1: Must have JSONB column
140        if type_def.jsonb_column.is_empty() {
141            return false;
142        }
143
144        // Condition 2a: Sufficient field count (>10 fields = likely significant overhead)
145        if type_def.fields.len() > 10 {
146            return true;
147        }
148
149        // Condition 2b: Likely large payload (estimate ~150 bytes per field)
150        // Average field: id (50B) + name (100B) + value (100B) = 250B overhead
151        // 1KB threshold = ~4+ fields of average size
152        let estimated_size = type_def.fields.len() * 250;
153        if estimated_size > 1024 {
154            return true;
155        }
156
157        false
158    }
159
160    /// Create a SQL projection hint for PostgreSQL.
161    ///
162    /// The hint contains:
163    /// - Database type: "postgresql"
164    /// - Projection template: `jsonb_build_object('field1', data->>'field1', ...)`
165    /// - Estimated reduction: Based on field count and typical JSONB overhead
166    fn create_projection_hint(type_def: &TypeDefinition) -> SqlProjectionHint {
167        // Estimate payload reduction based on field count and JSONB overhead
168        // Formula: Each unselected field = ~250 bytes saved (conservative estimate)
169        // Average type: 20 fields, 5 selected = 15 fields Ɨ 250B = 3750B saved = 95% reduction
170        let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
171
172        SqlProjectionHint {
173            database:                    "postgresql".to_string(),
174            projection_template:         Self::generate_postgresql_projection_template(type_def),
175            estimated_reduction_percent: estimated_reduction,
176        }
177    }
178
179    /// Estimate the percentage of payload that can be reduced through SQL projection.
180    ///
181    /// Based on benchmarks:
182    /// - Baseline payload: ~9.8 KB for typical large type
183    /// - Projected payload: ~450 B (select 5 key fields)
184    /// - Reduction: 95.4%
185    ///
186    /// Conservative scaling formula:
187    /// - Few fields (5-10): 40% reduction (mostly JSONB overhead, few wasted fields)
188    /// - Many fields (11-20): 70% reduction (more unselected fields)
189    /// - Very many fields (20+): 85% reduction (mostly unnecessary data)
190    const fn estimate_reduction_percent(field_count: usize) -> u32 {
191        match field_count {
192            0..=10 => 40,
193            11..=20 => 70,
194            _ => 85,
195        }
196    }
197
198    /// Estimate total payload savings in bytes for a type.
199    fn estimate_payload_savings(type_def: &TypeDefinition) -> usize {
200        let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
201        // Assume baseline JSONB payload ~250 bytes per field
202        let total_payload = type_def.fields.len() * 250;
203        (total_payload * estimated_reduction as usize) / 100
204    }
205
206    /// Generate a PostgreSQL `jsonb_build_object` template for SQL projection.
207    ///
208    /// Example output:
209    /// `jsonb_build_object`('id', data->>'id', 'name', data->>'name', 'email', data->>'email')
210    ///
211    /// Note: This is a template. At runtime, the adapter will:
212    /// 1. Receive the requested GraphQL fields
213    /// 2. Filter to only include requested fields
214    /// 3. Generate the actual SQL with selected fields only
215    fn generate_postgresql_projection_template(type_def: &TypeDefinition) -> String {
216        if type_def.fields.is_empty() {
217            // Edge case: type with no fields, use pass-through
218            "data".to_string()
219        } else {
220            // Create template with first N fields (up to 20 as representative)
221            let field_list: Vec<String> = type_def
222                .fields
223                .iter()
224                .take(20)
225                .map(|f| format!("'{}', data->>'{}' ", f.name, f.name))
226                .collect();
227
228            format!("jsonb_build_object({})", field_list.join(","))
229        }
230    }
231}
232
233/// Optimization report generated during compilation
234#[derive(Debug, Default)]
235pub struct OptimizationReport {
236    /// Index suggestions for query performance
237    pub index_hints:        Vec<IndexHint>,
238    /// SQL projection hints for types that would benefit from JSONB field filtering
239    pub projection_hints:   Vec<ProjectionHint>,
240    /// General optimization notes
241    pub optimization_notes: Vec<String>,
242}
243
244impl OptimizationReport {
245    /// Get total number of optimization hints
246    pub fn total_hints(&self) -> usize {
247        self.index_hints.len() + self.projection_hints.len() + self.optimization_notes.len()
248    }
249
250    /// Check if there are any optimization suggestions
251    pub fn has_suggestions(&self) -> bool {
252        !self.index_hints.is_empty()
253            || !self.projection_hints.is_empty()
254            || !self.optimization_notes.is_empty()
255    }
256
257    /// Print report to stdout
258    pub fn print(&self) {
259        if !self.has_suggestions() {
260            return;
261        }
262
263        println!("\nšŸ“Š Optimization Suggestions:");
264
265        if !self.index_hints.is_empty() {
266            println!("\n  Indexes:");
267            for hint in &self.index_hints {
268                println!("  • Query '{}': {}", hint.query_name, hint.reason);
269                println!("    Columns: {}", hint.suggested_columns.join(", "));
270            }
271        }
272
273        if !self.projection_hints.is_empty() {
274            println!("\n  SQL Projection Optimization:");
275            for hint in &self.projection_hints {
276                println!(
277                    "  • Type '{}' ({} fields): ~{}% payload reduction",
278                    hint.type_name, hint.field_count, hint.estimated_reduction_percent
279                );
280            }
281        }
282
283        if !self.optimization_notes.is_empty() {
284            println!("\n  Notes:");
285            for note in &self.optimization_notes {
286                println!("  • {note}");
287            }
288        }
289
290        println!();
291    }
292}
293
294/// Index hint for query optimization
295#[derive(Debug, Clone)]
296pub struct IndexHint {
297    /// Query name that would benefit from index
298    pub query_name:        String,
299    /// Reason for the suggestion
300    pub reason:            String,
301    /// Suggested columns to index
302    pub suggested_columns: Vec<String>,
303}
304
305/// SQL projection hint for type optimization
306#[derive(Debug, Clone)]
307pub struct ProjectionHint {
308    /// Type name that would benefit from SQL projection
309    pub type_name:                   String,
310    /// Number of fields in the type
311    pub field_count:                 usize,
312    /// Estimated payload reduction percentage (0-100)
313    pub estimated_reduction_percent: u32,
314}
315
316#[cfg(test)]
317mod tests {
318    use std::collections::HashMap;
319
320    use fraiseql_core::{
321        schema::{
322            ArgumentDefinition, AutoParams, CursorType, FieldDefinition, FieldDenyPolicy, FieldType,
323            TypeDefinition,
324        },
325        validation::CustomTypeRegistry,
326    };
327    use indexmap::IndexMap;
328
329    use super::*;
330
331    #[test]
332    fn test_optimize_empty_schema() {
333        let mut schema = CompiledSchema {
334            types:          vec![],
335            enums:          vec![],
336            input_types:    vec![],
337            interfaces:     vec![],
338            unions:         vec![],
339            queries:        vec![],
340            mutations:      vec![],
341            subscriptions:  vec![],
342            directives:     vec![],
343            observers:      Vec::new(),
344            fact_tables:    HashMap::default(),
345            federation:     None,
346            security:       None,
347            observers_config: None,
348            subscriptions_config: None,
349            validation_config: None,
350            debug_config:      None,
351            mcp_config:        None,
352            schema_sdl:     None,
353            custom_scalars: CustomTypeRegistry::default(),
354        };
355
356        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
357        assert_eq!(report.total_hints(), 0);
358    }
359
360    #[test]
361    fn test_index_hint_for_list_query() {
362        let mut schema = CompiledSchema {
363            types:          vec![],
364            enums:          vec![],
365            input_types:    vec![],
366            interfaces:     vec![],
367            unions:         vec![],
368            queries:        vec![QueryDefinition {
369                name:         "users".to_string(),
370                return_type:  "User".to_string(),
371                returns_list: true,
372                nullable:     false,
373                arguments:    vec![ArgumentDefinition {
374                    name:          "status".to_string(),
375                    arg_type:      FieldType::String,
376                    nullable:      false,
377                    default_value: None,
378                    description:   None,
379                    deprecation:   None,
380                }],
381                sql_source:   Some("users".to_string()),
382                description:  None,
383                auto_params:  AutoParams::default(),
384                deprecation:  None,
385                jsonb_column: "data".to_string(),
386                relay: false,
387                relay_cursor_column: None,
388                relay_cursor_type: CursorType::default(),
389                inject_params: IndexMap::default(),
390                cache_ttl_seconds: None,
391                additional_views: vec![],
392                requires_role:       None,
393            }],
394            mutations:      vec![],
395            subscriptions:  vec![],
396            directives:     vec![],
397            observers:      Vec::new(),
398            fact_tables:    HashMap::default(),
399            federation:     None,
400            security:       None,
401            observers_config: None,
402            subscriptions_config: None,
403            validation_config: None,
404            debug_config:      None,
405            mcp_config:        None,
406            schema_sdl:     None,
407            custom_scalars: CustomTypeRegistry::default(),
408        };
409
410        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
411        assert!(report.total_hints() > 0);
412        assert!(!report.index_hints.is_empty());
413        assert_eq!(report.index_hints[0].query_name, "users");
414    }
415
416    #[test]
417    fn test_pagination_note() {
418        let mut schema = CompiledSchema {
419            types:          vec![],
420            enums:          vec![],
421            input_types:    vec![],
422            interfaces:     vec![],
423            unions:         vec![],
424            queries:        vec![QueryDefinition {
425                name:         "products".to_string(),
426                return_type:  "Product".to_string(),
427                returns_list: true,
428                nullable:     false,
429                arguments:    vec![],
430                sql_source:   Some("products".to_string()),
431                description:  None,
432                auto_params:  AutoParams {
433                    has_where:    false,
434                    has_order_by: false,
435                    has_limit:    true,
436                    has_offset:   true,
437                },
438                deprecation:  None,
439                jsonb_column: "data".to_string(),
440                relay: false,
441                relay_cursor_column: None,
442                relay_cursor_type: CursorType::default(),
443                inject_params: IndexMap::default(),
444                cache_ttl_seconds: None,
445                additional_views: vec![],
446                requires_role:       None,
447            }],
448            mutations:      vec![],
449            subscriptions:  vec![],
450            directives:     vec![],
451            observers:      Vec::new(),
452            fact_tables:    HashMap::default(),
453            federation:     None,
454            security:       None,
455            observers_config: None,
456            subscriptions_config: None,
457            validation_config: None,
458            debug_config:      None,
459            mcp_config:        None,
460            schema_sdl:     None,
461            custom_scalars: CustomTypeRegistry::default(),
462        };
463
464        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
465        assert!(report.optimization_notes.iter().any(|note| note.contains("pagination")));
466    }
467
468    #[test]
469    fn test_large_type_warning() {
470        let mut schema = CompiledSchema {
471            types:          vec![TypeDefinition {
472                name:                "BigType".to_string(),
473                sql_source:          String::new(),
474                jsonb_column:        String::new(),
475                fields:              (0..25)
476                    .map(|i| FieldDefinition {
477                        name:           format!("field{i}"),
478                        field_type:     FieldType::String,
479                        nullable:       false,
480                        default_value:  None,
481                        description:    None,
482                        vector_config:  None,
483                        alias:          None,
484                        deprecation:    None,
485                        requires_scope: None,
486                        on_deny: FieldDenyPolicy::default(),
487                        encryption:     None,
488                    })
489                    .collect(),
490                description:         None,
491                sql_projection_hint: None,
492                implements:          vec![],
493                requires_role:       None,
494                is_error:            false,
495                relay:            false,
496            }],
497            enums:          vec![],
498            input_types:    vec![],
499            interfaces:     vec![],
500            unions:         vec![],
501            queries:        vec![],
502            mutations:      vec![],
503            subscriptions:  vec![],
504            directives:     vec![],
505            observers:      Vec::new(),
506            fact_tables:    HashMap::default(),
507            federation:     None,
508            security:       None,
509            observers_config: None,
510            subscriptions_config: None,
511            validation_config: None,
512            debug_config:      None,
513            mcp_config:        None,
514            schema_sdl:     None,
515            custom_scalars: CustomTypeRegistry::default(),
516        };
517
518        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
519        assert!(report.optimization_notes.iter().any(|note| note.contains("25 fields")));
520    }
521
522    #[test]
523    fn test_projection_hint_for_large_type() {
524        let mut schema = CompiledSchema {
525            types:          vec![TypeDefinition {
526                name:                "User".to_string(),
527                sql_source:          "users".to_string(),
528                jsonb_column:        "data".to_string(),
529                fields:              (0..15)
530                    .map(|i| FieldDefinition {
531                        name:           format!("field{i}"),
532                        field_type:     FieldType::String,
533                        nullable:       false,
534                        default_value:  None,
535                        description:    None,
536                        vector_config:  None,
537                        alias:          None,
538                        deprecation:    None,
539                        requires_scope: None,
540                        on_deny: FieldDenyPolicy::default(),
541                        encryption:     None,
542                    })
543                    .collect(),
544                description:         None,
545                sql_projection_hint: None,
546                implements:          vec![],
547                requires_role:       None,
548                is_error:            false,
549                relay:            false,
550            }],
551            enums:          vec![],
552            input_types:    vec![],
553            interfaces:     vec![],
554            unions:         vec![],
555            queries:        vec![],
556            mutations:      vec![],
557            subscriptions:  vec![],
558            directives:     vec![],
559            observers:      Vec::new(),
560            fact_tables:    HashMap::default(),
561            federation:     None,
562            security:       None,
563            observers_config: None,
564            subscriptions_config: None,
565            validation_config: None,
566            debug_config:      None,
567            mcp_config:        None,
568            schema_sdl:     None,
569            custom_scalars: CustomTypeRegistry::default(),
570        };
571
572        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
573
574        // Type with 15 fields and JSONB column should get projection hint
575        assert!(!report.projection_hints.is_empty());
576        assert_eq!(report.projection_hints[0].type_name, "User");
577        assert_eq!(report.projection_hints[0].field_count, 15);
578
579        // Type should have sql_projection_hint set
580        assert!(schema.types[0].has_sql_projection());
581        let hint = schema.types[0].sql_projection_hint.as_ref().unwrap();
582        assert_eq!(hint.database, "postgresql");
583        assert!(hint.estimated_reduction_percent > 0);
584    }
585
586    #[test]
587    fn test_projection_not_applied_without_jsonb() {
588        let mut schema = CompiledSchema {
589            types:          vec![TypeDefinition {
590                name:                "SmallType".to_string(),
591                sql_source:          "small_table".to_string(),
592                jsonb_column:        String::new(), // No JSONB column
593                fields:              (0..15)
594                    .map(|i| FieldDefinition {
595                        name:           format!("field{i}"),
596                        field_type:     FieldType::String,
597                        nullable:       false,
598                        default_value:  None,
599                        description:    None,
600                        vector_config:  None,
601                        alias:          None,
602                        deprecation:    None,
603                        requires_scope: None,
604                        on_deny: FieldDenyPolicy::default(),
605                        encryption:     None,
606                    })
607                    .collect(),
608                description:         None,
609                sql_projection_hint: None,
610                implements:          vec![],
611                requires_role:       None,
612                is_error:            false,
613                relay:            false,
614            }],
615            enums:          vec![],
616            input_types:    vec![],
617            interfaces:     vec![],
618            unions:         vec![],
619            queries:        vec![],
620            mutations:      vec![],
621            subscriptions:  vec![],
622            directives:     vec![],
623            observers:      Vec::new(),
624            fact_tables:    HashMap::default(),
625            federation:     None,
626            security:       None,
627            observers_config: None,
628            subscriptions_config: None,
629            validation_config: None,
630            debug_config:      None,
631            mcp_config:        None,
632            schema_sdl:     None,
633            custom_scalars: CustomTypeRegistry::default(),
634        };
635
636        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
637
638        // Type without JSONB column should not get projection hint
639        assert!(report.projection_hints.is_empty());
640        assert!(!schema.types[0].has_sql_projection());
641    }
642}