1use anyhow::Result;
7use fraiseql_core::schema::{CompiledSchema, QueryDefinition, SqlProjectionHint, TypeDefinition};
8use tracing::{debug, info};
9
10pub struct SchemaOptimizer;
12
13impl SchemaOptimizer {
14 pub fn optimize(schema: &mut CompiledSchema) -> Result<OptimizationReport> {
22 info!("Optimizing compiled schema");
23
24 let mut report = OptimizationReport::default();
25
26 for query in &schema.queries {
28 Self::analyze_query(query, &mut report);
29 }
30
31 Self::analyze_types(schema, &mut report);
33
34 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 fn analyze_query(query: &QueryDefinition, report: &mut OptimizationReport) {
44 debug!("Analyzing query: {}", query.name);
45
46 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 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 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 fn analyze_types(schema: &CompiledSchema, report: &mut OptimizationReport) {
74 for type_def in &schema.types {
75 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 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 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 fn should_use_projection(type_def: &TypeDefinition) -> bool {
139 if type_def.jsonb_column.is_empty() {
141 return false;
142 }
143
144 if type_def.fields.len() > 10 {
146 return true;
147 }
148
149 let estimated_size = type_def.fields.len() * 250;
153 if estimated_size > 1024 {
154 return true;
155 }
156
157 false
158 }
159
160 fn create_projection_hint(type_def: &TypeDefinition) -> SqlProjectionHint {
167 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 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 fn estimate_payload_savings(type_def: &TypeDefinition) -> usize {
200 let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
201 let total_payload = type_def.fields.len() * 250;
203 (total_payload * estimated_reduction as usize) / 100
204 }
205
206 fn generate_postgresql_projection_template(type_def: &TypeDefinition) -> String {
216 if type_def.fields.is_empty() {
217 "data".to_string()
219 } else {
220 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#[derive(Debug, Default)]
235pub struct OptimizationReport {
236 pub index_hints: Vec<IndexHint>,
238 pub projection_hints: Vec<ProjectionHint>,
240 pub optimization_notes: Vec<String>,
242}
243
244impl OptimizationReport {
245 pub fn total_hints(&self) -> usize {
247 self.index_hints.len() + self.projection_hints.len() + self.optimization_notes.len()
248 }
249
250 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 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#[derive(Debug, Clone)]
296pub struct IndexHint {
297 pub query_name: String,
299 pub reason: String,
301 pub suggested_columns: Vec<String>,
303}
304
305#[derive(Debug, Clone)]
307pub struct ProjectionHint {
308 pub type_name: String,
310 pub field_count: usize,
312 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 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 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(), 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 assert!(report.projection_hints.is_empty());
640 assert!(!schema.types[0].has_sql_projection());
641 }
642}