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::{
8    db::types::DatabaseType,
9    schema::{CompiledSchema, QueryDefinition, SqlProjectionHint, TypeDefinition},
10};
11use tracing::{debug, info};
12
13/// Schema optimizer that analyzes queries and adds SQL hints
14pub struct SchemaOptimizer;
15
16impl SchemaOptimizer {
17    /// Optimize a compiled schema
18    ///
19    /// This analyzes queries and adds optimization hints like:
20    /// - Index suggestions
21    /// - Join order hints
22    /// - Projection optimization
23    /// - Predicate pushdown opportunities
24    ///
25    /// # Errors
26    ///
27    /// Currently infallible; returns `Ok` in all cases. The `Result` return
28    /// type is reserved for future optimizations that may produce errors.
29    pub fn optimize(schema: &mut CompiledSchema) -> Result<OptimizationReport> {
30        info!("Optimizing compiled schema");
31
32        let mut report = OptimizationReport::default();
33
34        // Analyze queries for optimization opportunities
35        for query in &schema.queries {
36            Self::analyze_query(query, &mut report);
37        }
38
39        // Analyze types for field access patterns and SQL projection opportunities
40        Self::analyze_types(schema, &mut report);
41
42        // Detect and apply SQL projection hints to types that would benefit
43        Self::apply_sql_projection_hints(schema, &mut report);
44
45        info!("Schema optimization complete: {} hints generated", report.total_hints());
46
47        Ok(report)
48    }
49
50    /// Analyze a single query for optimization opportunities
51    fn analyze_query(query: &QueryDefinition, report: &mut OptimizationReport) {
52        debug!("Analyzing query: {}", query.name);
53
54        // Check if query would benefit from indexes
55        if query.returns_list && !query.arguments.is_empty() {
56            report.index_hints.push(IndexHint {
57                query_name:        query.name.clone(),
58                reason:            "List query with arguments benefits from index".to_string(),
59                suggested_columns: query.arguments.iter().map(|arg| arg.name.clone()).collect(),
60            });
61        }
62
63        // Check for auto-params that suggest filtering
64        if query.auto_params.has_where {
65            report.optimization_notes.push(format!(
66                "Query '{}' supports WHERE filtering - ensure denormalized filter columns exist",
67                query.name
68            ));
69        }
70
71        // Check for pagination
72        if query.auto_params.has_limit || query.auto_params.has_offset {
73            report.optimization_notes.push(format!(
74                "Query '{}' supports pagination - consider adding ORDER BY for deterministic results",
75                query.name
76            ));
77        }
78    }
79
80    /// Analyze types for field access patterns
81    fn analyze_types(schema: &CompiledSchema, report: &mut OptimizationReport) {
82        for type_def in &schema.types {
83            // Check for large number of fields (potential over-fetching)
84            if type_def.fields.len() > 20 {
85                report.optimization_notes.push(format!(
86                    "Type '{}' has {} fields - consider field selection optimization",
87                    type_def.name,
88                    type_def.fields.len()
89                ));
90            }
91
92            // Check for JSONB columns
93            if !type_def.jsonb_column.is_empty() {
94                report.optimization_notes.push(format!(
95                    "Type '{}' uses JSONB column '{}' - ensure GIN index exists for performance",
96                    type_def.name, type_def.jsonb_column
97                ));
98            }
99        }
100    }
101
102    /// Detect and apply SQL projection hints to types that would benefit from SQL-level field
103    /// projection.
104    ///
105    /// SQL projection optimization works by filtering JSONB fields at the database level,
106    /// reducing network payload and JSON deserialization overhead.
107    ///
108    /// Detection heuristics:
109    /// - Type must have a JSONB column
110    /// - Type should have sufficient fields (>10) or estimated large payload (>1KB)
111    /// - PostgreSQL benefit: reduced payload and latency (proportional to fields omitted; run
112    ///   `cargo bench --bench sql_projection_benchmark` for hardware-specific numbers)
113    fn apply_sql_projection_hints(schema: &mut CompiledSchema, report: &mut OptimizationReport) {
114        for type_def in &mut schema.types {
115            if Self::should_use_projection(type_def) {
116                let hint = Self::create_projection_hint(type_def);
117
118                debug!(
119                    "Type '{}' qualifies for SQL projection: {} bytes saved ({:.0}%)",
120                    type_def.name,
121                    Self::estimate_payload_savings(type_def),
122                    hint.estimated_reduction_percent
123                );
124
125                type_def.sql_projection_hint = Some(hint);
126                report.projection_hints.push(ProjectionHint {
127                    type_name:                   type_def.name.to_string(),
128                    field_count:                 type_def.fields.len(),
129                    estimated_reduction_percent: type_def
130                        .sql_projection_hint
131                        .as_ref()
132                        .map_or(0, |h| h.estimated_reduction_percent),
133                });
134            }
135        }
136    }
137
138    /// Determine if a type should use SQL projection optimization.
139    ///
140    /// A type qualifies for SQL projection if:
141    /// 1. It has a JSONB column (`store_format` == "jsonb")
142    /// 2. It has sufficient fields (>10) OR estimated large payload (>1KB)
143    ///
144    /// Rationale: SQL projection's benefit (reducing JSONB payload) is most valuable
145    /// for types with many fields or large payloads. Small types don't benefit enough
146    /// to justify the SQL generation overhead.
147    fn should_use_projection(type_def: &TypeDefinition) -> bool {
148        // Condition 1: Must have JSONB column
149        if type_def.jsonb_column.is_empty() {
150            return false;
151        }
152
153        // Condition 2a: Sufficient field count (>10 fields = likely significant overhead)
154        if type_def.fields.len() > 10 {
155            return true;
156        }
157
158        // Condition 2b: Likely large payload (estimate ~150 bytes per field)
159        // Average field: id (50B) + name (100B) + value (100B) = 250B overhead
160        // 1KB threshold = ~4+ fields of average size
161        let estimated_size = type_def.fields.len() * 250;
162        if estimated_size > 1024 {
163            return true;
164        }
165
166        false
167    }
168
169    /// Create a SQL projection hint for PostgreSQL.
170    ///
171    /// The hint contains:
172    /// - Database type: "postgresql"
173    /// - Projection template: `jsonb_build_object('field1', data->>'field1', ...)`
174    /// - Estimated reduction: Based on field count and typical JSONB overhead
175    fn create_projection_hint(type_def: &TypeDefinition) -> SqlProjectionHint {
176        // Rough estimate: each unselected field ≈ 250 bytes saved (conservative).
177        // Actual reduction varies by schema and query pattern; not a guaranteed figure.
178        let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
179
180        SqlProjectionHint::new(
181            DatabaseType::PostgreSQL,
182            Self::generate_postgresql_projection_template(type_def),
183            estimated_reduction,
184        )
185    }
186
187    /// Estimate the percentage of payload that can be reduced through SQL projection.
188    ///
189    /// Based on benchmarks:
190    /// - Baseline payload: ~9.8 KB for typical large type
191    /// - Projected payload: ~450 B (select 5 key fields)
192    /// - Reduction: 95.4%
193    ///
194    /// Conservative scaling formula:
195    /// - Few fields (5-10): 40% reduction (mostly JSONB overhead, few wasted fields)
196    /// - Many fields (11-20): 70% reduction (more unselected fields)
197    /// - Very many fields (20+): 85% reduction (mostly unnecessary data)
198    const fn estimate_reduction_percent(field_count: usize) -> u32 {
199        match field_count {
200            0..=10 => 40,
201            11..=20 => 70,
202            _ => 85,
203        }
204    }
205
206    /// Estimate total payload savings in bytes for a type.
207    fn estimate_payload_savings(type_def: &TypeDefinition) -> usize {
208        let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
209        // Assume baseline JSONB payload ~250 bytes per field
210        let total_payload = type_def.fields.len() * 250;
211        (total_payload * estimated_reduction as usize) / 100
212    }
213
214    /// Generate a PostgreSQL `jsonb_build_object` template for SQL projection.
215    ///
216    /// Example output:
217    /// `jsonb_build_object`('id', data->>'id', 'name', data->>'name', 'email', data->>'email')
218    ///
219    /// Note: This is a template. At runtime, the adapter will:
220    /// 1. Receive the requested GraphQL fields
221    /// 2. Filter to only include requested fields
222    /// 3. Generate the actual SQL with selected fields only
223    fn generate_postgresql_projection_template(type_def: &TypeDefinition) -> String {
224        if type_def.fields.is_empty() {
225            // Edge case: type with no fields, use pass-through
226            "data".to_string()
227        } else {
228            // Create template with first N fields (up to 20 as representative)
229            let field_list: Vec<String> = type_def
230                .fields
231                .iter()
232                .take(20)
233                .map(|f| format!("'{}', data->>'{}' ", f.name, f.name))
234                .collect();
235
236            format!("jsonb_build_object({})", field_list.join(","))
237        }
238    }
239}
240
241/// Optimization report generated during compilation
242#[derive(Debug, Default)]
243pub struct OptimizationReport {
244    /// Index suggestions for query performance
245    pub index_hints:        Vec<IndexHint>,
246    /// SQL projection hints for types that would benefit from JSONB field filtering
247    pub projection_hints:   Vec<ProjectionHint>,
248    /// General optimization notes
249    pub optimization_notes: Vec<String>,
250}
251
252impl OptimizationReport {
253    /// Get total number of optimization hints
254    pub fn total_hints(&self) -> usize {
255        self.index_hints.len() + self.projection_hints.len() + self.optimization_notes.len()
256    }
257
258    /// Check if there are any optimization suggestions
259    pub fn has_suggestions(&self) -> bool {
260        !self.index_hints.is_empty()
261            || !self.projection_hints.is_empty()
262            || !self.optimization_notes.is_empty()
263    }
264
265    /// Print report to stdout
266    pub fn print(&self) {
267        if !self.has_suggestions() {
268            return;
269        }
270
271        println!("\nOptimization Suggestions:");
272
273        if !self.index_hints.is_empty() {
274            println!("\n  Indexes:");
275            for hint in &self.index_hints {
276                println!("  • Query '{}': {}", hint.query_name, hint.reason);
277                println!("    Columns: {}", hint.suggested_columns.join(", "));
278            }
279        }
280
281        if !self.projection_hints.is_empty() {
282            println!("\n  SQL Projection Optimization:");
283            for hint in &self.projection_hints {
284                println!(
285                    "  • Type '{}' ({} fields): ~{}% payload reduction",
286                    hint.type_name, hint.field_count, hint.estimated_reduction_percent
287                );
288            }
289        }
290
291        if !self.optimization_notes.is_empty() {
292            println!("\n  Notes:");
293            for note in &self.optimization_notes {
294                println!("  • {note}");
295            }
296        }
297
298        println!();
299    }
300}
301
302/// Index hint for query optimization
303#[derive(Debug, Clone)]
304pub struct IndexHint {
305    /// Query name that would benefit from index
306    pub query_name:        String,
307    /// Reason for the suggestion
308    pub reason:            String,
309    /// Suggested columns to index
310    pub suggested_columns: Vec<String>,
311}
312
313/// SQL projection hint for type optimization
314#[derive(Debug, Clone)]
315pub struct ProjectionHint {
316    /// Type name that would benefit from SQL projection
317    pub type_name:                   String,
318    /// Number of fields in the type
319    pub field_count:                 usize,
320    /// Estimated payload reduction percentage (0-100)
321    pub estimated_reduction_percent: u32,
322}