1use crate::stats::FieldStats;
2use crate::types::JsonType;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum IndexType {
8 Gin,
10 Partial,
12 BTreeExtracted,
14}
15
16impl IndexType {
17 pub fn to_name(&self) -> &str {
18 match self {
19 IndexType::Gin => "GIN",
20 IndexType::Partial => "Partial GIN",
21 IndexType::BTreeExtracted => "B-tree (extracted)",
22 }
23 }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub enum IndexPriority {
29 High,
31 Medium,
33 Low,
35}
36
37impl IndexPriority {
38 pub fn to_name(&self) -> &str {
39 match self {
40 IndexPriority::High => "High",
41 IndexPriority::Medium => "Medium",
42 IndexPriority::Low => "Low",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IndexRecommendation {
50 pub field_path: String,
51 pub index_type: IndexType,
52 pub priority: IndexPriority,
53 pub reason: String,
54 pub sql: String,
55 pub estimated_benefit: String,
56}
57
58#[derive(Debug, Clone)]
60pub struct IndexConfig {
61 pub high_density_threshold: f64,
63 pub medium_density_threshold: f64,
65 pub min_occurences: u64,
67}
68
69impl Default for IndexConfig {
70 fn default() -> Self {
71 IndexConfig {
72 high_density_threshold: 0.8,
73 medium_density_threshold: 0.2,
74 min_occurences: 100,
75 }
76 }
77}
78
79pub fn recommend_index(
81 table: &str,
82 column: &str,
83 field_stats: &[FieldStats],
84 config: &IndexConfig,
85) -> Vec<IndexRecommendation> {
86 let mut recommendations = Vec::new();
87
88 let high_density_fields: Vec<&FieldStats> = field_stats
90 .iter()
91 .filter(|s| {
92 s.occurrences >= config.min_occurences
93 && s.density >= config.high_density_threshold
94 && !matches!(
95 get_dominant_type(s),
96 Some(JsonType::Object) | Some(JsonType::Array)
97 )
98 })
99 .collect();
100
101 if !high_density_fields.is_empty() {
103 let primary_field = high_density_fields
105 .iter()
106 .max_by(|a, b| {
107 a.density
108 .partial_cmp(&b.density)
109 .unwrap_or(std::cmp::Ordering::Equal)
110 })
111 .unwrap();
112
113 recommendations.push(create_consolidated_gin_recommendation(
114 table,
115 column,
116 primary_field,
117 &high_density_fields,
118 IndexPriority::Medium,
119 ));
120 }
121
122 for stats in field_stats {
124 if stats.occurrences < config.min_occurences {
125 continue;
126 }
127
128 let dominant_type = get_dominant_type(stats);
129 if dominant_type == Some(JsonType::Object) || dominant_type == Some(JsonType::Array) {
130 continue;
131 }
132
133 if stats.density >= config.high_density_threshold {
135 continue;
136 }
137
138 if stats.density > 0.0 && stats.density <= config.medium_density_threshold {
139 recommendations.push(create_partial_gin_recommendation(
140 table,
141 column,
142 stats,
143 IndexPriority::Medium,
144 ));
145 } else if stats.density > config.medium_density_threshold
146 && stats.density < config.high_density_threshold
147 && is_scalar_type(dominant_type)
148 {
149 recommendations.push(create_btree_extracted_recommendation(
150 table,
151 column,
152 stats,
153 dominant_type.unwrap(),
154 IndexPriority::Medium,
155 ));
156 }
157 }
158
159 recommendations.sort_by(|a, b| {
160 let priority_order = |p: &IndexPriority| match p {
161 IndexPriority::High => 0,
162 IndexPriority::Medium => 1,
163 IndexPriority::Low => 2,
164 };
165 priority_order(&a.priority).cmp(&priority_order(&b.priority))
166 });
167
168 recommendations
169}
170
171fn get_dominant_type(stats: &FieldStats) -> Option<JsonType> {
172 stats
173 .types
174 .iter()
175 .max_by_key(|(_, count)| *count)
176 .map(|(json_type, _)| *json_type)
177}
178
179fn is_scalar_type(json_type: Option<JsonType>) -> bool {
180 matches!(
181 json_type,
182 Some(JsonType::String) | Some(JsonType::Number) | Some(JsonType::Boolean)
183 )
184}
185
186fn create_consolidated_gin_recommendation(
187 table: &str,
188 column: &str,
189 primary_stats: &FieldStats,
190 all_high_density: &[&FieldStats],
191 priority: IndexPriority,
192) -> IndexRecommendation {
193 let index_name = generate_index_name(table, column, "gin", "gin");
194
195 let field_list = all_high_density
197 .iter()
198 .map(|s| format!("{} ({:.1}%)", s.path, s.density * 100.0))
199 .collect::<Vec<_>>()
200 .join(", ");
201
202 let sql = format!(
203 "-- GIN index for high-density fields: {}\n\
204 CREATE INDEX {} ON {} USING GIN ({});",
205 field_list, index_name, table, column
206 );
207
208 let reason = if all_high_density.len() == 1 {
209 format!(
210 "High density ({:.1}%) - present in {}/{} samples. \
211 GIN index enables fast JSONB queries (@>, ?, ?&, ?|)",
212 primary_stats.density * 100.0,
213 primary_stats.occurrences,
214 primary_stats.total_samples
215 )
216 } else {
217 format!(
218 "{} high-density fields ({}). \
219 Single GIN index supports fast JSONB queries (@>, ?, ?&, ?|) for all fields.",
220 all_high_density.len(),
221 field_list
222 )
223 };
224
225 IndexRecommendation {
226 field_path: primary_stats.path.clone(),
227 index_type: IndexType::Gin,
228 priority,
229 reason,
230 sql,
231 estimated_benefit: "Improved query performance for existence checks and containment queries across all high-density fields.".to_string(),
232 }
233}
234
235fn create_partial_gin_recommendation(
236 table: &str,
237 column: &str,
238 stats: &FieldStats,
239 priority: IndexPriority,
240) -> IndexRecommendation {
241 let index_name = generate_index_name(table, column, &stats.path, "partial_gin");
242 let path_condition = json_path_to_sql_conditions(&stats.path);
243 let sql = format!(
244 "-- Partial GIN index for sparse field: {:.1}% of rows contain this field\n\
245 CREATE INDEX {} ON {} USING GIN ({}) WHERE {};",
246 stats.density * 100.0,
247 index_name,
248 table,
249 column,
250 path_condition
251 );
252
253 IndexRecommendation {
254 field_path: stats.path.clone(),
255 index_type: IndexType::Partial,
256 priority,
257 reason: format!(
258 "Sparce field ({:.1}%) - only {}/{} sample have this field. \
259 Partial index reduces index size and maintenance cost",
260 stats.density * 100.0,
261 stats.occurrences,
262 stats.total_samples
263 ),
264 sql,
265 estimated_benefit: format!(
266 "Smaller index (~{:.1}% of full GIN), faster updates, same query performance for matching rows",
267 stats.density * 100.0
268 ),
269 }
270}
271
272fn create_btree_extracted_recommendation(
273 table: &str,
274 column: &str,
275 stats: &FieldStats,
276 json_type: JsonType,
277 priority: IndexPriority,
278) -> IndexRecommendation {
279 let index_name = generate_index_name(table, column, &stats.path, "btree_ext");
280 let (extraction_expr, pg_type) = match json_type {
281 JsonType::String => (
282 format!("({} #>> '{{{}}}')", column, escape_json_path(&stats.path)),
283 "TEXT",
284 ),
285 JsonType::Number => (
286 format!(
287 "(({} #>> '{{{}}}')::NUMERIC)",
288 column,
289 escape_json_path(&stats.path)
290 ),
291 "NUMERIC",
292 ),
293 JsonType::Boolean => (
294 format!(
295 "(({} #>> '{{{}}}')::BOOLEAN)",
296 column,
297 escape_json_path(&stats.path)
298 ),
299 "BOOLEAN",
300 ),
301 _ => unreachable!(),
302 };
303
304 let sql = format!(
305 "-- B-tree index on extracted {} value: {:.1}% density\n\
306 CREATE INDEX {} ON {} ({}) WHERE {} IS NOT NULL;",
307 pg_type,
308 stats.density * 100.0,
309 index_name,
310 table,
311 extraction_expr,
312 extraction_expr
313 );
314
315 IndexRecommendation {
316 field_path: stats.path.clone(),
317 index_type: IndexType::BTreeExtracted,
318 priority,
319 reason: format!(
320 "Medium density {} field ({:.1}%) - {}/{} samples. \
321 B-tree index on extracted value range query and sorting.",
322 json_type,
323 stats.density * 100.0,
324 stats.occurrences,
325 stats.total_samples
326 ),
327 sql,
328 estimated_benefit:
329 "Improved query performance for lookups and range queries on scalar values.".to_string(),
330 }
331}
332
333fn generate_index_name(table: &str, column: &str, path: &str, index_type: &str) -> String {
334 let clean_path = path
335 .replace("[]", "_arr")
336 .replace(".", "_")
337 .chars()
338 .filter(|c| c.is_alphanumeric() || *c == '_')
339 .collect::<String>();
340
341 let max_len = 63;
342 let prefix = format!("idx_{}_{}_{}_{}", table, column, clean_path, index_type);
343
344 if prefix.len() <= max_len {
345 prefix
346 } else {
347 let hash = format!("{:x}", calculate_simple_hash(&prefix));
348 let truncate_len = max_len - hash.len() - 1;
349 format!("{}_{}", &prefix[..truncate_len], hash)
350 }
351}
352
353fn calculate_simple_hash(s: &str) -> u32 {
354 s.bytes()
355 .fold(0u32, |hash, b| hash.wrapping_mul(31).wrapping_add(b as u32))
356}
357
358fn json_path_to_sql_conditions(path: &str) -> String {
359 let parts: Vec<&str> = path.split('.').collect();
360 if parts.len() == 1 {
361 let clean_part = parts[0].replace("[]", "");
362 format!("metadata ? '{}'", clean_part)
363 } else {
364 let parent_path = parts[..parts.len() - 1]
365 .iter()
366 .map(|p| p.replace("[]", ""))
367 .collect::<Vec<_>>()
368 .join(",");
369 let last = parts.last().unwrap().replace("[]", "");
370 format!("metadata #> '{{{}}}' ? '{}'", parent_path, last)
371 }
372}
373
374fn escape_json_path(path: &str) -> String {
375 path.replace("[]", "") .replace('\'', "''") .replace('.', ",") }
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn create_test_stats(path: &str, density: f64, occurrences: u64, total: u64) -> FieldStats {
385 let mut stats = FieldStats::new(path.to_string(), 1);
386 stats.occurrences = occurrences;
387 stats.total_samples = total;
388 stats.density = density;
389 stats
390 }
391
392 #[test]
393 fn test_high_density_recommends_gin() {
394 let mut stats = create_test_stats("user.email", 0.95, 9500, 10000);
395 stats.types.insert(JsonType::String, 9500);
396
397 let config = IndexConfig::default();
398 let recommendations = recommend_index("users", "metadata", &[stats], &config);
399
400 assert_eq!(recommendations.len(), 1);
401 assert_eq!(recommendations[0].index_type, IndexType::Gin);
402 assert_eq!(recommendations[0].priority, IndexPriority::Medium);
403 assert!(recommendations[0].sql.contains("CREATE INDEX"));
404 assert!(recommendations[0].sql.contains("USING GIN"));
405 assert!(recommendations[0].sql.contains("95.0%"));
406 }
407
408 #[test]
409 fn test_sparse_field_recommends_partial_gin() {
410 let mut stats = create_test_stats("billing.legacy_plan", 0.05, 100, 2000);
411 stats.types.insert(JsonType::String, 100);
412
413 let config = IndexConfig::default();
414 let recommendations = recommend_index("users", "metadata", &[stats], &config);
415
416 assert_eq!(recommendations.len(), 1);
417 assert_eq!(recommendations[0].index_type, IndexType::Partial);
418 assert_eq!(recommendations[0].priority, IndexPriority::Medium);
419 assert!(recommendations[0].sql.contains("WHERE"));
420 assert!(recommendations[0].sql.contains("5.0%"));
421 assert!(recommendations[0].estimated_benefit.contains("5.0%"));
422 }
423
424 #[test]
425 fn test_medium_density_string_recommends_btree() {
426 let mut stats = create_test_stats("username", 0.5, 500, 1000);
427 stats.types.insert(JsonType::String, 500);
428
429 let config = IndexConfig::default();
430 let recommendations = recommend_index("users", "metadata", &[stats], &config);
431
432 assert_eq!(recommendations.len(), 1);
433 assert_eq!(recommendations[0].index_type, IndexType::BTreeExtracted);
434 assert_eq!(recommendations[0].priority, IndexPriority::Medium);
435 assert!(recommendations[0].sql.contains("#>>"));
436 assert!(recommendations[0].sql.contains("TEXT"));
437 }
438
439 #[test]
440 fn test_medium_density_number_recommends_btree() {
441 let mut stats = create_test_stats("age", 0.6, 600, 1000);
442 stats.types.insert(JsonType::Number, 600);
443
444 let config = IndexConfig::default();
445 let recommendations = recommend_index("users", "metadata", &[stats], &config);
446
447 assert_eq!(recommendations.len(), 1);
448 assert_eq!(recommendations[0].index_type, IndexType::BTreeExtracted);
449 assert!(recommendations[0].sql.contains("::NUMERIC"));
450 assert!(recommendations[0].sql.contains("NUMERIC"));
451 }
452
453 #[test]
454 fn test_medium_density_boolean_recommends_btree() {
455 let mut stats = create_test_stats("is_active", 0.45, 450, 1000);
456 stats.types.insert(JsonType::Boolean, 450);
457
458 let config = IndexConfig::default();
459 let recommendations = recommend_index("users", "metadata", &[stats], &config);
460
461 assert_eq!(recommendations.len(), 1);
462 assert_eq!(recommendations[0].index_type, IndexType::BTreeExtracted);
463 assert!(recommendations[0].sql.contains("::BOOLEAN"));
464 assert!(recommendations[0].sql.contains("BOOLEAN"));
465 }
466
467 #[test]
468 fn test_skips_object_types() {
469 let mut stats = create_test_stats("user", 0.9, 900, 1000);
470 stats.types.insert(JsonType::Object, 900);
471
472 let config = IndexConfig::default();
473 let recommendations = recommend_index("users", "metadata", &[stats], &config);
474
475 assert_eq!(recommendations.len(), 0);
476 }
477
478 #[test]
479 fn test_skips_array_types() {
480 let mut stats = create_test_stats("tags", 0.9, 900, 1000);
481 stats.types.insert(JsonType::Array, 900);
482
483 let config = IndexConfig::default();
484 let recommendations = recommend_index("users", "metadata", &[stats], &config);
485
486 assert_eq!(recommendations.len(), 0);
487 }
488
489 #[test]
490 fn test_skips_low_occurrence_fields() {
491 let mut stats = create_test_stats("rare_field", 0.9, 50, 55);
492 stats.types.insert(JsonType::String, 50);
493
494 let config = IndexConfig::default();
495 let recommendations = recommend_index("users", "metadata", &[stats], &config);
496
497 assert_eq!(recommendations.len(), 0);
498 }
499
500 #[test]
501 fn test_respects_min_occurrences_threshold() {
502 let mut stats = create_test_stats("field", 0.9, 99, 110);
503 stats.types.insert(JsonType::String, 99);
504
505 let config = IndexConfig::default();
506 let recommendations = recommend_index("users", "metadata", &[stats], &config);
507
508 assert_eq!(recommendations.len(), 0);
510
511 let mut stats2 = create_test_stats("field2", 0.9, 100, 111);
512 stats2.types.insert(JsonType::String, 100);
513
514 let recommendations2 = recommend_index("users", "metadata", &[stats2], &config);
515
516 assert_eq!(recommendations2.len(), 1);
518 }
519
520 #[test]
521 fn test_custom_high_density_threshold() {
522 let mut stats = create_test_stats("field", 0.7, 700, 1000);
523 stats.types.insert(JsonType::String, 700);
524
525 let config = IndexConfig {
526 high_density_threshold: 0.6,
527 medium_density_threshold: 0.2,
528 min_occurences: 100,
529 };
530
531 let recommendations = recommend_index("users", "metadata", &[stats], &config);
532
533 assert_eq!(recommendations.len(), 1);
534 assert_eq!(recommendations[0].index_type, IndexType::Gin);
535 }
536
537 #[test]
538 fn test_custom_medium_density_threshold() {
539 let mut stats = create_test_stats("field", 0.15, 150, 1000);
540 stats.types.insert(JsonType::String, 150);
541
542 let config = IndexConfig {
543 high_density_threshold: 0.8,
544 medium_density_threshold: 0.1,
545 min_occurences: 100,
546 };
547
548 let recommendations = recommend_index("users", "metadata", &[stats], &config);
549
550 assert_eq!(recommendations.len(), 1);
552 assert_eq!(recommendations[0].index_type, IndexType::BTreeExtracted);
553 }
554
555 #[test]
556 fn test_index_name_generation_basic() {
557 let name = generate_index_name("users", "metadata", "user.email", "gin");
558 assert_eq!(name, "idx_users_metadata_user_email_gin");
559 assert!(name.len() <= 63);
560 }
561
562 #[test]
563 fn test_index_name_generation_with_arrays() {
564 let name = generate_index_name("orders", "data", "items[].sku", "btree_ext");
565 assert!(name.contains("items_arr_sku"));
566 assert!(name.len() <= 63);
567 }
568
569 #[test]
570 fn test_index_name_generation_truncation() {
571 let long_path = "very.long.deeply.nested.path.that.will.definitely.exceed.the.postgresql.limit.for.index.names";
572 let name = generate_index_name("table_with_very_long_name", "column", long_path, "gin");
573 assert!(name.len() <= 63);
574 assert!(name.starts_with("idx_"));
575 }
576
577 #[test]
578 fn test_index_name_special_chars_removed() {
579 let name = generate_index_name("users", "data", "user-email@domain", "gin");
580 assert!(!name.contains('@'));
582 assert!(!name.contains('-'));
583 }
584
585 #[test]
586 fn test_json_path_escaping() {
587 assert_eq!(escape_json_path("user.email"), "user,email");
589 assert_eq!(escape_json_path("tags[]"), "tags");
590 assert_eq!(escape_json_path("user's.name"), "user''s,name");
592 assert_eq!(escape_json_path("items[].price"), "items,price");
594 }
595
596 #[test]
597 fn test_json_path_to_sql_conditions_simple() {
598 let condition = json_path_to_sql_conditions("email");
599 assert_eq!(condition, "metadata ? 'email'");
600 }
601
602 #[test]
603 fn test_json_path_to_sql_conditions_nested() {
604 let condition = json_path_to_sql_conditions("user.profile.email");
605 assert_eq!(condition, "metadata #> '{user,profile}' ? 'email'");
606 }
607
608 #[test]
609 fn test_json_path_to_sql_conditions_with_array() {
610 let condition = json_path_to_sql_conditions("tags[]");
611 assert_eq!(condition, "metadata ? 'tags'");
612 }
613
614 #[test]
615 fn test_priority_sorting() {
616 let mut stats1 = create_test_stats("low_priority", 0.5, 500, 1000);
617 stats1.types.insert(JsonType::String, 500);
618
619 let mut stats2 = create_test_stats("high_priority", 0.95, 950, 1000);
620 stats2.types.insert(JsonType::String, 950);
621
622 let mut stats3 = create_test_stats("medium_priority", 0.1, 100, 1000);
623 stats3.types.insert(JsonType::String, 100);
624
625 let config = IndexConfig::default();
626 let recommendations =
627 recommend_index("users", "metadata", &[stats1, stats2, stats3], &config);
628
629 assert_eq!(recommendations.len(), 3);
630 assert!(recommendations[0].priority == IndexPriority::Medium);
632 assert!(recommendations[1].priority == IndexPriority::Medium);
633 assert!(recommendations[2].priority == IndexPriority::Medium);
634 }
635
636 #[test]
637 fn test_multiple_recommendations() {
638 let mut stats1 = create_test_stats("email", 0.95, 950, 1000);
639 stats1.types.insert(JsonType::String, 950);
640
641 let mut stats2 = create_test_stats("age", 0.6, 600, 1000);
642 stats2.types.insert(JsonType::Number, 600);
643
644 let mut stats3 = create_test_stats("legacy_id", 0.05, 100, 2000);
645 stats3.types.insert(JsonType::String, 100);
646
647 let config = IndexConfig::default();
648 let recommendations =
649 recommend_index("users", "metadata", &[stats1, stats2, stats3], &config);
650
651 assert_eq!(recommendations.len(), 3);
652
653 let gin = recommendations
655 .iter()
656 .find(|r| r.index_type == IndexType::Gin);
657 let btree = recommendations
658 .iter()
659 .find(|r| r.index_type == IndexType::BTreeExtracted);
660 let partial = recommendations
661 .iter()
662 .find(|r| r.index_type == IndexType::Partial);
663
664 assert!(gin.is_some());
665 assert!(btree.is_some());
666 assert!(partial.is_some());
667 }
668
669 #[test]
670 fn test_multiple_high_density_creates_single_gin() {
671 let mut stats1 = create_test_stats("email", 0.95, 950, 1000);
672 stats1.types.insert(JsonType::String, 950);
673
674 let mut stats2 = create_test_stats("name", 0.92, 920, 1000);
675 stats2.types.insert(JsonType::String, 920);
676
677 let mut stats3 = create_test_stats("status", 0.88, 880, 1000);
678 stats3.types.insert(JsonType::String, 880);
679
680 let mut stats4 = create_test_stats("user_id", 1.0, 1000, 1000);
681 stats4.types.insert(JsonType::String, 1000);
682
683 let config = IndexConfig::default();
684 let recommendations = recommend_index(
685 "users",
686 "metadata",
687 &[stats1, stats2, stats3, stats4],
688 &config,
689 );
690
691 let gin_count = recommendations
693 .iter()
694 .filter(|r| r.index_type == IndexType::Gin)
695 .count();
696 assert_eq!(
697 gin_count, 1,
698 "Should generate single GIN index for multiple high-density fields"
699 );
700
701 let gin_rec = recommendations
703 .iter()
704 .find(|r| r.index_type == IndexType::Gin)
705 .unwrap();
706 assert!(gin_rec.reason.contains("email") || gin_rec.sql.contains("email"));
707 assert!(gin_rec.reason.contains("name") || gin_rec.sql.contains("name"));
708 assert!(gin_rec.reason.contains("status") || gin_rec.sql.contains("status"));
709 assert!(gin_rec.reason.contains("user_id") || gin_rec.sql.contains("user_id"));
710 assert!(gin_rec.reason.contains("4 high-density fields"));
711 }
712
713 #[test]
714 fn test_get_dominant_type() {
715 let mut stats = create_test_stats("mixed_field", 0.5, 500, 1000);
716 stats.types.insert(JsonType::String, 450);
717 stats.types.insert(JsonType::Number, 50);
718
719 let dominant = get_dominant_type(&stats);
720 assert_eq!(dominant, Some(JsonType::String));
721 }
722
723 #[test]
724 fn test_is_scalar_type() {
725 assert!(is_scalar_type(Some(JsonType::String)));
726 assert!(is_scalar_type(Some(JsonType::Number)));
727 assert!(is_scalar_type(Some(JsonType::Boolean)));
728 assert!(!is_scalar_type(Some(JsonType::Object)));
729 assert!(!is_scalar_type(Some(JsonType::Array)));
730 assert!(!is_scalar_type(Some(JsonType::Null)));
731 assert!(!is_scalar_type(None));
732 }
733
734 #[test]
735 fn test_index_type_to_name() {
736 assert_eq!(IndexType::Gin.to_name(), "GIN");
737 assert_eq!(IndexType::Partial.to_name(), "Partial GIN");
738 assert_eq!(IndexType::BTreeExtracted.to_name(), "B-tree (extracted)");
739 }
740
741 #[test]
742 fn test_index_priority_to_name() {
743 assert_eq!(IndexPriority::High.to_name(), "High");
744 assert_eq!(IndexPriority::Medium.to_name(), "Medium");
745 assert_eq!(IndexPriority::Low.to_name(), "Low");
746 }
747
748 #[test]
749 fn test_calculate_simple_hash() {
750 let hash1 = calculate_simple_hash("test");
751 let hash2 = calculate_simple_hash("test");
752 let hash3 = calculate_simple_hash("different");
753
754 assert_eq!(hash1, hash2);
756 assert_ne!(hash1, hash3);
758 }
759
760 #[test]
761 fn test_recommendation_contains_field_path() {
762 let mut stats = create_test_stats("user.profile.email", 0.9, 900, 1000);
763 stats.types.insert(JsonType::String, 900);
764
765 let config = IndexConfig::default();
766 let recommendations = recommend_index("users", "metadata", &[stats], &config);
767
768 assert_eq!(recommendations.len(), 1);
769 assert_eq!(recommendations[0].field_path, "user.profile.email");
770 }
771
772 #[test]
773 fn test_gin_recommendation_has_complete_sql() {
774 let mut stats = create_test_stats("email", 0.95, 950, 1000);
775 stats.types.insert(JsonType::String, 950);
776
777 let config = IndexConfig::default();
778 let recommendations = recommend_index("users", "metadata", &[stats], &config);
779
780 let sql = &recommendations[0].sql;
781 assert!(sql.contains("CREATE INDEX"));
782 assert!(sql.contains("USING GIN"));
783 assert!(sql.contains("users"));
784 assert!(sql.contains("metadata"));
785 assert!(sql.starts_with("--")); }
787
788 #[test]
789 fn test_partial_gin_has_where_clause() {
790 let mut stats = create_test_stats("rare.field", 0.05, 100, 2000);
791 stats.types.insert(JsonType::String, 100);
792
793 let config = IndexConfig::default();
794 let recommendations = recommend_index("users", "metadata", &[stats], &config);
795
796 let sql = &recommendations[0].sql;
797 assert!(sql.contains("WHERE"));
798 assert!(sql.contains("metadata"));
799 }
800
801 #[test]
802 fn test_btree_has_extraction_and_where() {
803 let mut stats = create_test_stats("score", 0.5, 500, 1000);
804 stats.types.insert(JsonType::Number, 500);
805
806 let config = IndexConfig::default();
807 let recommendations = recommend_index("users", "metadata", &[stats], &config);
808
809 let sql = &recommendations[0].sql;
810 assert!(sql.contains("#>>"));
811 assert!(sql.contains("::NUMERIC"));
812 assert!(sql.contains("WHERE"));
813 assert!(sql.contains("IS NOT NULL"));
814 }
815}