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