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