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::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 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 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(), 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 assert!(report.projection_hints.is_empty());
568 assert!(!schema.types[0].has_sql_projection());
569 }
570}