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::{ArgumentDefinition, AutoParams, FieldDefinition, FieldType, TypeDefinition},
322        validation::CustomTypeRegistry,
323    };
324
325    use super::*;
326
327    #[test]
328    fn test_optimize_empty_schema() {
329        let mut schema = CompiledSchema {
330            types:          vec![],
331            enums:          vec![],
332            input_types:    vec![],
333            interfaces:     vec![],
334            unions:         vec![],
335            queries:        vec![],
336            mutations:      vec![],
337            subscriptions:  vec![],
338            directives:     vec![],
339            observers:      Vec::new(),
340            fact_tables:    HashMap::default(),
341            federation:     None,
342            security:       None,
343            observers_config: None,
344            schema_sdl:     None,
345            custom_scalars: CustomTypeRegistry::default(),
346        };
347
348        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
349        assert_eq!(report.total_hints(), 0);
350    }
351
352    #[test]
353    fn test_index_hint_for_list_query() {
354        let mut schema = CompiledSchema {
355            types:          vec![],
356            enums:          vec![],
357            input_types:    vec![],
358            interfaces:     vec![],
359            unions:         vec![],
360            queries:        vec![QueryDefinition {
361                name:         "users".to_string(),
362                return_type:  "User".to_string(),
363                returns_list: true,
364                nullable:     false,
365                arguments:    vec![ArgumentDefinition {
366                    name:          "status".to_string(),
367                    arg_type:      FieldType::String,
368                    nullable:      false,
369                    default_value: None,
370                    description:   None,
371                    deprecation:   None,
372                }],
373                sql_source:   Some("users".to_string()),
374                description:  None,
375                auto_params:  AutoParams::default(),
376                deprecation:  None,
377                jsonb_column: "data".to_string(),
378            }],
379            mutations:      vec![],
380            subscriptions:  vec![],
381            directives:     vec![],
382            observers:      Vec::new(),
383            fact_tables:    HashMap::default(),
384            federation:     None,
385            security:       None,
386            observers_config: None,
387            schema_sdl:     None,
388            custom_scalars: CustomTypeRegistry::default(),
389        };
390
391        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
392        assert!(report.total_hints() > 0);
393        assert!(!report.index_hints.is_empty());
394        assert_eq!(report.index_hints[0].query_name, "users");
395    }
396
397    #[test]
398    fn test_pagination_note() {
399        let mut schema = CompiledSchema {
400            types:          vec![],
401            enums:          vec![],
402            input_types:    vec![],
403            interfaces:     vec![],
404            unions:         vec![],
405            queries:        vec![QueryDefinition {
406                name:         "products".to_string(),
407                return_type:  "Product".to_string(),
408                returns_list: true,
409                nullable:     false,
410                arguments:    vec![],
411                sql_source:   Some("products".to_string()),
412                description:  None,
413                auto_params:  AutoParams {
414                    has_where:    false,
415                    has_order_by: false,
416                    has_limit:    true,
417                    has_offset:   true,
418                },
419                deprecation:  None,
420                jsonb_column: "data".to_string(),
421            }],
422            mutations:      vec![],
423            subscriptions:  vec![],
424            directives:     vec![],
425            observers:      Vec::new(),
426            fact_tables:    HashMap::default(),
427            federation:     None,
428            security:       None,
429            observers_config: None,
430            schema_sdl:     None,
431            custom_scalars: CustomTypeRegistry::default(),
432        };
433
434        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
435        assert!(report.optimization_notes.iter().any(|note| note.contains("pagination")));
436    }
437
438    #[test]
439    fn test_large_type_warning() {
440        let mut schema = CompiledSchema {
441            types:          vec![TypeDefinition {
442                name:                "BigType".to_string(),
443                sql_source:          String::new(),
444                jsonb_column:        String::new(),
445                fields:              (0..25)
446                    .map(|i| FieldDefinition {
447                        name:           format!("field{i}"),
448                        field_type:     FieldType::String,
449                        nullable:       false,
450                        default_value:  None,
451                        description:    None,
452                        vector_config:  None,
453                        alias:          None,
454                        deprecation:    None,
455                        requires_scope: None,
456                        encryption:     None,
457                    })
458                    .collect(),
459                description:         None,
460                sql_projection_hint: None,
461                implements:          vec![],
462                is_error:            false,
463            }],
464            enums:          vec![],
465            input_types:    vec![],
466            interfaces:     vec![],
467            unions:         vec![],
468            queries:        vec![],
469            mutations:      vec![],
470            subscriptions:  vec![],
471            directives:     vec![],
472            observers:      Vec::new(),
473            fact_tables:    HashMap::default(),
474            federation:     None,
475            security:       None,
476            observers_config: None,
477            schema_sdl:     None,
478            custom_scalars: CustomTypeRegistry::default(),
479        };
480
481        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
482        assert!(report.optimization_notes.iter().any(|note| note.contains("25 fields")));
483    }
484
485    #[test]
486    fn test_projection_hint_for_large_type() {
487        let mut schema = CompiledSchema {
488            types:          vec![TypeDefinition {
489                name:                "User".to_string(),
490                sql_source:          "users".to_string(),
491                jsonb_column:        "data".to_string(),
492                fields:              (0..15)
493                    .map(|i| FieldDefinition {
494                        name:           format!("field{i}"),
495                        field_type:     FieldType::String,
496                        nullable:       false,
497                        default_value:  None,
498                        description:    None,
499                        vector_config:  None,
500                        alias:          None,
501                        deprecation:    None,
502                        requires_scope: None,
503                        encryption:     None,
504                    })
505                    .collect(),
506                description:         None,
507                sql_projection_hint: None,
508                implements:          vec![],
509                is_error:            false,
510            }],
511            enums:          vec![],
512            input_types:    vec![],
513            interfaces:     vec![],
514            unions:         vec![],
515            queries:        vec![],
516            mutations:      vec![],
517            subscriptions:  vec![],
518            directives:     vec![],
519            observers:      Vec::new(),
520            fact_tables:    HashMap::default(),
521            federation:     None,
522            security:       None,
523            observers_config: None,
524            schema_sdl:     None,
525            custom_scalars: CustomTypeRegistry::default(),
526        };
527
528        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
529
530        // Type with 15 fields and JSONB column should get projection hint
531        assert!(!report.projection_hints.is_empty());
532        assert_eq!(report.projection_hints[0].type_name, "User");
533        assert_eq!(report.projection_hints[0].field_count, 15);
534
535        // Type should have sql_projection_hint set
536        assert!(schema.types[0].has_sql_projection());
537        let hint = schema.types[0].sql_projection_hint.as_ref().unwrap();
538        assert_eq!(hint.database, "postgresql");
539        assert!(hint.estimated_reduction_percent > 0);
540    }
541
542    #[test]
543    fn test_projection_not_applied_without_jsonb() {
544        let mut schema = CompiledSchema {
545            types:          vec![TypeDefinition {
546                name:                "SmallType".to_string(),
547                sql_source:          "small_table".to_string(),
548                jsonb_column:        String::new(), // No JSONB column
549                fields:              (0..15)
550                    .map(|i| FieldDefinition {
551                        name:           format!("field{i}"),
552                        field_type:     FieldType::String,
553                        nullable:       false,
554                        default_value:  None,
555                        description:    None,
556                        vector_config:  None,
557                        alias:          None,
558                        deprecation:    None,
559                        requires_scope: None,
560                        encryption:     None,
561                    })
562                    .collect(),
563                description:         None,
564                sql_projection_hint: None,
565                implements:          vec![],
566                is_error:            false,
567            }],
568            enums:          vec![],
569            input_types:    vec![],
570            interfaces:     vec![],
571            unions:         vec![],
572            queries:        vec![],
573            mutations:      vec![],
574            subscriptions:  vec![],
575            directives:     vec![],
576            observers:      Vec::new(),
577            fact_tables:    HashMap::default(),
578            federation:     None,
579            security:       None,
580            observers_config: None,
581            schema_sdl:     None,
582            custom_scalars: CustomTypeRegistry::default(),
583        };
584
585        let report = SchemaOptimizer::optimize(&mut schema).unwrap();
586
587        // Type without JSONB column should not get projection hint
588        assert!(report.projection_hints.is_empty());
589        assert!(!schema.types[0].has_sql_projection());
590    }
591}