1use anyhow::Result;
7use fraiseql_core::{
8 db::types::DatabaseType,
9 schema::{CompiledSchema, QueryDefinition, SqlProjectionHint, TypeDefinition},
10};
11use tracing::{debug, info};
12
13pub struct SchemaOptimizer;
15
16impl SchemaOptimizer {
17 pub fn optimize(schema: &mut CompiledSchema) -> Result<OptimizationReport> {
30 info!("Optimizing compiled schema");
31
32 let mut report = OptimizationReport::default();
33
34 for query in &schema.queries {
36 Self::analyze_query(query, &mut report);
37 }
38
39 Self::analyze_types(schema, &mut report);
41
42 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 fn analyze_query(query: &QueryDefinition, report: &mut OptimizationReport) {
52 debug!("Analyzing query: {}", query.name);
53
54 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 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 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 fn analyze_types(schema: &CompiledSchema, report: &mut OptimizationReport) {
82 for type_def in &schema.types {
83 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 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 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 fn should_use_projection(type_def: &TypeDefinition) -> bool {
148 if type_def.jsonb_column.is_empty() {
150 return false;
151 }
152
153 if type_def.fields.len() > 10 {
155 return true;
156 }
157
158 let estimated_size = type_def.fields.len() * 250;
162 if estimated_size > 1024 {
163 return true;
164 }
165
166 false
167 }
168
169 fn create_projection_hint(type_def: &TypeDefinition) -> SqlProjectionHint {
176 let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
179
180 SqlProjectionHint {
181 database: DatabaseType::PostgreSQL,
182 projection_template: Self::generate_postgresql_projection_template(type_def),
183 estimated_reduction_percent: estimated_reduction,
184 }
185 }
186
187 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 fn estimate_payload_savings(type_def: &TypeDefinition) -> usize {
208 let estimated_reduction = Self::estimate_reduction_percent(type_def.fields.len());
209 let total_payload = type_def.fields.len() * 250;
211 (total_payload * estimated_reduction as usize) / 100
212 }
213
214 fn generate_postgresql_projection_template(type_def: &TypeDefinition) -> String {
224 if type_def.fields.is_empty() {
225 "data".to_string()
227 } else {
228 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#[derive(Debug, Default)]
243pub struct OptimizationReport {
244 pub index_hints: Vec<IndexHint>,
246 pub projection_hints: Vec<ProjectionHint>,
248 pub optimization_notes: Vec<String>,
250}
251
252impl OptimizationReport {
253 pub fn total_hints(&self) -> usize {
255 self.index_hints.len() + self.projection_hints.len() + self.optimization_notes.len()
256 }
257
258 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 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#[derive(Debug, Clone)]
304pub struct IndexHint {
305 pub query_name: String,
307 pub reason: String,
309 pub suggested_columns: Vec<String>,
311}
312
313#[derive(Debug, Clone)]
315pub struct ProjectionHint {
316 pub type_name: String,
318 pub field_count: usize,
320 pub estimated_reduction_percent: u32,
322}
323
324#[allow(clippy::unwrap_used)] #[cfg(test)]
326mod tests {
327 use std::collections::HashMap;
328
329 use fraiseql_core::{
330 schema::{
331 ArgumentDefinition, AutoParams, CursorType, FieldDefinition, FieldDenyPolicy,
332 FieldType, TypeDefinition,
333 },
334 validation::CustomTypeRegistry,
335 };
336 use indexmap::IndexMap;
337
338 use super::*;
339
340 #[test]
341 fn test_optimize_empty_schema() {
342 let mut schema = CompiledSchema {
343 types: vec![],
344 enums: vec![],
345 input_types: vec![],
346 interfaces: vec![],
347 unions: vec![],
348 queries: vec![],
349 mutations: vec![],
350 subscriptions: vec![],
351 directives: vec![],
352 observers: Vec::new(),
353 fact_tables: HashMap::default(),
354 federation: None,
355 security: None,
356 observers_config: None,
357 subscriptions_config: None,
358 validation_config: None,
359 debug_config: None,
360 mcp_config: None,
361 schema_sdl: None,
362 schema_format_version: None,
363 custom_scalars: CustomTypeRegistry::default(),
364 ..Default::default()
365 };
366
367 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
368 assert_eq!(report.total_hints(), 0);
369 }
370
371 #[test]
372 fn test_index_hint_for_list_query() {
373 let mut schema = CompiledSchema {
374 types: vec![],
375 enums: vec![],
376 input_types: vec![],
377 interfaces: vec![],
378 unions: vec![],
379 queries: vec![QueryDefinition {
380 name: "users".to_string(),
381 return_type: "User".to_string(),
382 returns_list: true,
383 nullable: false,
384 arguments: vec![ArgumentDefinition {
385 name: "status".to_string(),
386 arg_type: FieldType::String,
387 nullable: false,
388 default_value: None,
389 description: None,
390 deprecation: None,
391 }],
392 sql_source: Some("users".to_string()),
393 description: None,
394 auto_params: AutoParams::default(),
395 deprecation: None,
396 jsonb_column: "data".to_string(),
397 relay: false,
398 relay_cursor_column: None,
399 relay_cursor_type: CursorType::default(),
400 inject_params: IndexMap::default(),
401 cache_ttl_seconds: None,
402 additional_views: vec![],
403 requires_role: None,
404 rest_path: None,
405 rest_method: None,
406 }],
407 mutations: vec![],
408 subscriptions: vec![],
409 directives: vec![],
410 observers: Vec::new(),
411 fact_tables: HashMap::default(),
412 federation: None,
413 security: None,
414 observers_config: None,
415 subscriptions_config: None,
416 validation_config: None,
417 debug_config: None,
418 mcp_config: None,
419 schema_sdl: None,
420 schema_format_version: None,
421 custom_scalars: CustomTypeRegistry::default(),
422 ..Default::default()
423 };
424
425 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
426 assert!(report.total_hints() > 0);
427 assert!(!report.index_hints.is_empty());
428 assert_eq!(report.index_hints[0].query_name, "users");
429 }
430
431 #[test]
432 fn test_pagination_note() {
433 let mut schema = CompiledSchema {
434 types: vec![],
435 enums: vec![],
436 input_types: vec![],
437 interfaces: vec![],
438 unions: vec![],
439 queries: vec![QueryDefinition {
440 name: "products".to_string(),
441 return_type: "Product".to_string(),
442 returns_list: true,
443 nullable: false,
444 arguments: vec![],
445 sql_source: Some("products".to_string()),
446 description: None,
447 auto_params: AutoParams {
448 has_where: false,
449 has_order_by: false,
450 has_limit: true,
451 has_offset: true,
452 },
453 deprecation: None,
454 jsonb_column: "data".to_string(),
455 relay: false,
456 relay_cursor_column: None,
457 relay_cursor_type: CursorType::default(),
458 inject_params: IndexMap::default(),
459 cache_ttl_seconds: None,
460 additional_views: vec![],
461 requires_role: None,
462 rest_path: None,
463 rest_method: None,
464 }],
465 mutations: vec![],
466 subscriptions: vec![],
467 directives: vec![],
468 observers: Vec::new(),
469 fact_tables: HashMap::default(),
470 federation: None,
471 security: None,
472 observers_config: None,
473 subscriptions_config: None,
474 validation_config: None,
475 debug_config: None,
476 mcp_config: None,
477 schema_sdl: None,
478 schema_format_version: None,
479 custom_scalars: CustomTypeRegistry::default(),
480 ..Default::default()
481 };
482
483 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
484 assert!(report.optimization_notes.iter().any(|note| note.contains("pagination")));
485 }
486
487 #[test]
488 fn test_large_type_warning() {
489 let mut schema = CompiledSchema {
490 types: vec![TypeDefinition {
491 name: "BigType".into(),
492 sql_source: String::new().into(),
493 jsonb_column: String::new(),
494 fields: (0..25)
495 .map(|i| FieldDefinition {
496 name: format!("field{i}").into(),
497 field_type: FieldType::String,
498 nullable: false,
499 default_value: None,
500 description: None,
501 vector_config: None,
502 alias: None,
503 deprecation: None,
504 requires_scope: None,
505 on_deny: FieldDenyPolicy::default(),
506 encryption: None,
507 })
508 .collect(),
509 description: None,
510 sql_projection_hint: None,
511 implements: vec![],
512 requires_role: None,
513 is_error: false,
514 relay: false,
515 relationships: Vec::new(),
516 }],
517 enums: vec![],
518 input_types: vec![],
519 interfaces: vec![],
520 unions: vec![],
521 queries: vec![],
522 mutations: vec![],
523 subscriptions: vec![],
524 directives: vec![],
525 observers: Vec::new(),
526 fact_tables: HashMap::default(),
527 federation: None,
528 security: None,
529 observers_config: None,
530 subscriptions_config: None,
531 validation_config: None,
532 debug_config: None,
533 mcp_config: None,
534 schema_sdl: None,
535 schema_format_version: None,
536 custom_scalars: CustomTypeRegistry::default(),
537 ..Default::default()
538 };
539
540 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
541 assert!(report.optimization_notes.iter().any(|note| note.contains("25 fields")));
542 }
543
544 #[test]
545 fn test_projection_hint_for_large_type() {
546 let mut schema = CompiledSchema {
547 types: vec![TypeDefinition {
548 name: "User".into(),
549 sql_source: "users".into(),
550 jsonb_column: "data".to_string(),
551 fields: (0..15)
552 .map(|i| FieldDefinition {
553 name: format!("field{i}").into(),
554 field_type: FieldType::String,
555 nullable: false,
556 default_value: None,
557 description: None,
558 vector_config: None,
559 alias: None,
560 deprecation: None,
561 requires_scope: None,
562 on_deny: FieldDenyPolicy::default(),
563 encryption: None,
564 })
565 .collect(),
566 description: None,
567 sql_projection_hint: None,
568 implements: vec![],
569 requires_role: None,
570 is_error: false,
571 relay: false,
572 relationships: Vec::new(),
573 }],
574 enums: vec![],
575 input_types: vec![],
576 interfaces: vec![],
577 unions: vec![],
578 queries: vec![],
579 mutations: vec![],
580 subscriptions: vec![],
581 directives: vec![],
582 observers: Vec::new(),
583 fact_tables: HashMap::default(),
584 federation: None,
585 security: None,
586 observers_config: None,
587 subscriptions_config: None,
588 validation_config: None,
589 debug_config: None,
590 mcp_config: None,
591 schema_sdl: None,
592 schema_format_version: None,
593 custom_scalars: CustomTypeRegistry::default(),
594 ..Default::default()
595 };
596
597 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
598
599 assert!(!report.projection_hints.is_empty());
601 assert_eq!(report.projection_hints[0].type_name, "User");
602 assert_eq!(report.projection_hints[0].field_count, 15);
603
604 assert!(schema.types[0].has_sql_projection());
606 let hint = schema.types[0].sql_projection_hint.as_ref().unwrap();
607 assert_eq!(hint.database, DatabaseType::PostgreSQL);
608 assert!(hint.estimated_reduction_percent > 0);
609 }
610
611 #[test]
612 fn test_projection_not_applied_without_jsonb() {
613 let mut schema = CompiledSchema {
614 types: vec![TypeDefinition {
615 name: "SmallType".into(),
616 sql_source: "small_table".into(),
617 jsonb_column: String::new(), fields: (0..15)
619 .map(|i| FieldDefinition {
620 name: format!("field{i}").into(),
621 field_type: FieldType::String,
622 nullable: false,
623 default_value: None,
624 description: None,
625 vector_config: None,
626 alias: None,
627 deprecation: None,
628 requires_scope: None,
629 on_deny: FieldDenyPolicy::default(),
630 encryption: None,
631 })
632 .collect(),
633 description: None,
634 sql_projection_hint: None,
635 implements: vec![],
636 requires_role: None,
637 is_error: false,
638 relay: false,
639 relationships: Vec::new(),
640 }],
641 enums: vec![],
642 input_types: vec![],
643 interfaces: vec![],
644 unions: vec![],
645 queries: vec![],
646 mutations: vec![],
647 subscriptions: vec![],
648 directives: vec![],
649 observers: Vec::new(),
650 fact_tables: HashMap::default(),
651 federation: None,
652 security: None,
653 observers_config: None,
654 subscriptions_config: None,
655 validation_config: None,
656 debug_config: None,
657 mcp_config: None,
658 schema_sdl: None,
659 schema_format_version: None,
660 custom_scalars: CustomTypeRegistry::default(),
661 ..Default::default()
662 };
663
664 let report = SchemaOptimizer::optimize(&mut schema).unwrap();
665
666 assert!(report.projection_hints.is_empty());
668 assert!(!schema.types[0].has_sql_projection());
669 }
670}