1use super::value_range::{compare_histogram_value, range_bound_matches, ValueRangeBound};
16use crate::cardinality_defaults::{
17 NULL_SELECTIVITY_DIVISOR_CAP, RANGE_SELECTIVITY_DIVISOR, SAMPLED_HISTOGRAM_MATCH_PSEUDOCOUNT,
18 SAMPLED_HISTOGRAM_TOTAL_PSEUDOCOUNT,
19};
20use hawdb_core::{Catalog, GraphStatistics, IndexKind, Value};
21use hawdb_plan::ComparisonOp;
22use std::collections::{BTreeMap, BTreeSet};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub(super) struct ExpandEstimate {
26 pub(super) path_count: Option<u64>,
27 pub(super) rel_count: u64,
28 pub(super) source_count: u64,
29 pub(super) average_fanout: u64,
30 pub(super) property_distinct_product: u64,
31 pub(super) estimated_rows: u64,
32 pub(super) hop_estimates: Vec<HopEstimate>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(super) struct HopEstimate {
37 pub(super) hop: usize,
38 pub(super) rows: u64,
39 pub(super) exact: bool,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct OptimizerIndexStatistics {
44 pub index_size: u64,
45 pub distinct_count: u64,
46}
47
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct OptimizerCatalog {
50 pub(super) assume_all_indexes: bool,
51 pub(super) equality_property_indexes: BTreeSet<(String, String)>,
52 pub(super) composite_property_indexes: BTreeSet<(String, Vec<String>)>,
53 pub(super) range_property_indexes: BTreeSet<(String, String)>,
54 pub(super) full_text_property_indexes: BTreeSet<(String, String)>,
55 pub(super) label_counts: BTreeMap<String, u64>,
56 pub(super) rel_type_counts: BTreeMap<String, u64>,
57 pub(super) rel_type_source_counts: BTreeMap<String, u64>,
58 pub(super) rel_type_target_counts: BTreeMap<String, u64>,
59 pub(super) path_counts: BTreeMap<(String, String, String), u64>,
60 pub(super) path_source_distinct_counts: BTreeMap<(String, String, String), u64>,
61 pub(super) path_target_distinct_counts: BTreeMap<(String, String, String), u64>,
62 pub(super) bounded_path_counts: BTreeMap<(String, String, String, usize), u64>,
63 pub(super) bounded_path_source_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
64 pub(super) bounded_path_target_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
65 pub(super) property_index_statistics: BTreeMap<(String, String), OptimizerIndexStatistics>,
66 pub(super) composite_index_statistics:
67 BTreeMap<(String, Vec<String>), OptimizerIndexStatistics>,
68 pub(super) property_distinct_counts: BTreeMap<(String, String), u64>,
69 pub(super) rel_property_distinct_counts: BTreeMap<(String, String), u64>,
70 pub(super) property_histograms: BTreeMap<(String, String), Vec<Value>>,
71 pub(super) rel_property_histograms: BTreeMap<(String, String), Vec<Value>>,
72 pub(super) sampled_property_histograms: BTreeMap<(String, String), bool>,
73 pub(super) sampled_rel_property_histograms: BTreeMap<(String, String), bool>,
74}
75
76#[derive(Debug, Default, Clone, PartialEq, Eq)]
77pub struct OptimizerCatalogIndexes {
78 pub(super) equality_property_indexes: BTreeSet<(String, String)>,
79 pub(super) composite_property_indexes: BTreeSet<(String, Vec<String>)>,
80 pub(super) range_property_indexes: BTreeSet<(String, String)>,
81 pub(super) full_text_property_indexes: BTreeSet<(String, String)>,
82}
83
84#[derive(Debug, Default, Clone, PartialEq, Eq)]
85pub struct OptimizerCatalogStatistics {
86 pub(super) label_counts: BTreeMap<String, u64>,
87 pub(super) rel_type_counts: BTreeMap<String, u64>,
88 pub(super) rel_type_source_counts: BTreeMap<String, u64>,
89 pub(super) rel_type_target_counts: BTreeMap<String, u64>,
90 pub(super) path_counts: BTreeMap<(String, String, String), u64>,
91 pub(super) path_source_distinct_counts: BTreeMap<(String, String, String), u64>,
92 pub(super) path_target_distinct_counts: BTreeMap<(String, String, String), u64>,
93 pub(super) bounded_path_counts: BTreeMap<(String, String, String, usize), u64>,
94 pub(super) bounded_path_source_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
95 pub(super) bounded_path_target_distinct_counts: BTreeMap<(String, String, String, usize), u64>,
96 pub(super) property_index_statistics: BTreeMap<(String, String), OptimizerIndexStatistics>,
97 pub(super) composite_index_statistics:
98 BTreeMap<(String, Vec<String>), OptimizerIndexStatistics>,
99 pub(super) property_distinct_counts: BTreeMap<(String, String), u64>,
100 pub(super) rel_property_distinct_counts: BTreeMap<(String, String), u64>,
101 pub(super) property_histograms: BTreeMap<(String, String), Vec<Value>>,
102 pub(super) rel_property_histograms: BTreeMap<(String, String), Vec<Value>>,
103 pub(super) sampled_property_histograms: BTreeMap<(String, String), bool>,
104 pub(super) sampled_rel_property_histograms: BTreeMap<(String, String), bool>,
105}
106
107#[doc(hidden)]
113pub fn optimizer_catalog_from_graph_statistics(
114 catalog: &Catalog,
115 statistics: &GraphStatistics,
116) -> OptimizerCatalog {
117 let advanced_statistics = statistics
118 .advanced_statistics_complete
119 .then_some(statistics);
120 let equality_property_indexes = catalog.property_indexes().filter_map(|index| {
121 if index.kind != IndexKind::Equality {
122 return None;
123 }
124 catalog
125 .label_name(index.label_id)
126 .map(|label| (label.to_string(), index.property.clone()))
127 });
128 let composite_property_indexes = catalog.composite_property_indexes().filter_map(|index| {
129 catalog
130 .label_name(index.label_id)
131 .map(|label| (label.to_string(), index.properties.clone()))
132 });
133 let range_property_indexes = catalog.property_indexes().filter_map(|index| {
134 if index.kind != IndexKind::Range {
135 return None;
136 }
137 catalog
138 .label_name(index.label_id)
139 .map(|label| (label.to_string(), index.property.clone()))
140 });
141 let full_text_property_indexes = catalog.property_indexes().filter_map(|index| {
142 if index.kind != IndexKind::FullText {
143 return None;
144 }
145 catalog
146 .label_name(index.label_id)
147 .map(|label| (label.to_string(), index.property.clone()))
148 });
149 let label_counts = statistics
150 .label_counts
151 .iter()
152 .filter_map(|(label_id, count)| {
153 catalog
154 .label_name(*label_id)
155 .map(|label| (label.to_string(), *count))
156 });
157 let rel_type_counts = statistics
158 .rel_type_counts
159 .iter()
160 .filter_map(|(rel_type_id, count)| {
161 catalog
162 .rel_type_name(*rel_type_id)
163 .map(|rel_type| (rel_type.to_string(), *count))
164 });
165 let rel_type_source_counts = advanced_statistics
166 .into_iter()
167 .flat_map(|statistics| statistics.rel_type_source_counts.iter())
168 .filter_map(|(rel_type_id, count)| {
169 catalog
170 .rel_type_name(*rel_type_id)
171 .map(|rel_type| (rel_type.to_string(), *count))
172 });
173 let rel_type_target_counts = advanced_statistics
174 .into_iter()
175 .flat_map(|statistics| statistics.rel_type_target_counts.iter())
176 .filter_map(|(rel_type_id, count)| {
177 catalog
178 .rel_type_name(*rel_type_id)
179 .map(|rel_type| (rel_type.to_string(), *count))
180 });
181 let path_counts = advanced_statistics
182 .into_iter()
183 .flat_map(|statistics| statistics.path_counts.iter())
184 .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
185 Some((
186 (
187 catalog.label_name(*source_label_id)?.to_string(),
188 catalog.rel_type_name(*rel_type_id)?.to_string(),
189 catalog.label_name(*target_label_id)?.to_string(),
190 ),
191 *count,
192 ))
193 });
194 let path_source_distinct_counts = advanced_statistics
195 .into_iter()
196 .flat_map(|statistics| statistics.path_source_distinct_counts.iter())
197 .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
198 Some((
199 (
200 catalog.label_name(*source_label_id)?.to_string(),
201 catalog.rel_type_name(*rel_type_id)?.to_string(),
202 catalog.label_name(*target_label_id)?.to_string(),
203 ),
204 *count,
205 ))
206 });
207 let path_target_distinct_counts = advanced_statistics
208 .into_iter()
209 .flat_map(|statistics| statistics.path_target_distinct_counts.iter())
210 .filter_map(|((source_label_id, rel_type_id, target_label_id), count)| {
211 Some((
212 (
213 catalog.label_name(*source_label_id)?.to_string(),
214 catalog.rel_type_name(*rel_type_id)?.to_string(),
215 catalog.label_name(*target_label_id)?.to_string(),
216 ),
217 *count,
218 ))
219 });
220 let bounded_path_counts = advanced_statistics
221 .into_iter()
222 .flat_map(|statistics| statistics.bounded_path_counts.iter())
223 .filter_map(
224 |((source_label_id, rel_type_id, target_label_id, hops), count)| {
225 Some((
226 (
227 catalog.label_name(*source_label_id)?.to_string(),
228 catalog.rel_type_name(*rel_type_id)?.to_string(),
229 catalog.label_name(*target_label_id)?.to_string(),
230 *hops,
231 ),
232 *count,
233 ))
234 },
235 );
236 let bounded_path_source_distinct_counts = advanced_statistics
237 .into_iter()
238 .flat_map(|statistics| statistics.bounded_path_source_distinct_counts.iter())
239 .filter_map(
240 |((source_label_id, rel_type_id, target_label_id, hops), count)| {
241 Some((
242 (
243 catalog.label_name(*source_label_id)?.to_string(),
244 catalog.rel_type_name(*rel_type_id)?.to_string(),
245 catalog.label_name(*target_label_id)?.to_string(),
246 *hops,
247 ),
248 *count,
249 ))
250 },
251 );
252 let bounded_path_target_distinct_counts = advanced_statistics
253 .into_iter()
254 .flat_map(|statistics| statistics.bounded_path_target_distinct_counts.iter())
255 .filter_map(
256 |((source_label_id, rel_type_id, target_label_id, hops), count)| {
257 Some((
258 (
259 catalog.label_name(*source_label_id)?.to_string(),
260 catalog.rel_type_name(*rel_type_id)?.to_string(),
261 catalog.label_name(*target_label_id)?.to_string(),
262 *hops,
263 ),
264 *count,
265 ))
266 },
267 );
268 let property_distinct_counts = advanced_statistics
269 .into_iter()
270 .flat_map(|statistics| statistics.property_distinct_counts.iter())
271 .filter_map(|((label_id, property), count)| {
272 catalog
273 .label_name(*label_id)
274 .map(|label| ((label.to_string(), property.clone()), *count))
275 });
276 let property_histograms = advanced_statistics
277 .into_iter()
278 .flat_map(|statistics| statistics.property_histograms.iter())
279 .filter_map(|((label_id, property), values)| {
280 catalog
281 .label_name(*label_id)
282 .map(|label| ((label.to_string(), property.clone()), values.clone()))
283 });
284 let sampled_property_histograms = advanced_statistics
285 .into_iter()
286 .flat_map(|statistics| statistics.sampled_property_histograms.iter())
287 .filter_map(|((label_id, property), sampled)| {
288 catalog
289 .label_name(*label_id)
290 .map(|label| ((label.to_string(), property.clone()), *sampled))
291 });
292 let rel_property_distinct_counts = advanced_statistics
293 .into_iter()
294 .flat_map(|statistics| statistics.rel_property_distinct_counts.iter())
295 .filter_map(|((rel_type_id, property), count)| {
296 catalog
297 .rel_type_name(*rel_type_id)
298 .map(|rel_type| ((rel_type.to_string(), property.clone()), *count))
299 });
300 let rel_property_histograms = advanced_statistics
301 .into_iter()
302 .flat_map(|statistics| statistics.rel_property_histograms.iter())
303 .filter_map(|((rel_type_id, property), values)| {
304 catalog
305 .rel_type_name(*rel_type_id)
306 .map(|rel_type| ((rel_type.to_string(), property.clone()), values.clone()))
307 });
308 let sampled_rel_property_histograms = advanced_statistics
309 .into_iter()
310 .flat_map(|statistics| statistics.sampled_rel_property_histograms.iter())
311 .filter_map(|((rel_type_id, property), sampled)| {
312 catalog
313 .rel_type_name(*rel_type_id)
314 .map(|rel_type| ((rel_type.to_string(), property.clone()), *sampled))
315 });
316 let property_index_statistics = catalog.property_indexes().filter_map(|index| {
317 if index.kind == IndexKind::FullText {
318 return None;
319 }
320 let statistics = advanced_statistics?;
321 let sample = statistics.index_samples.get(&index.id)?;
322 let distinct_count = sample.estimated_unique_values()?;
323 let label = catalog.label_name(index.label_id)?;
324 Some((
325 (label.to_string(), index.property.clone()),
326 OptimizerIndexStatistics {
327 index_size: sample.index_size,
328 distinct_count,
329 },
330 ))
331 });
332 let composite_index_statistics = catalog.composite_property_indexes().filter_map(|index| {
333 let statistics = advanced_statistics?;
334 let sample = statistics.index_samples.get(&index.id)?;
335 let distinct_count = sample.estimated_unique_values()?;
336 let label = catalog.label_name(index.label_id)?;
337 Some((
338 (label.to_string(), index.properties.clone()),
339 OptimizerIndexStatistics {
340 index_size: sample.index_size,
341 distinct_count,
342 },
343 ))
344 });
345 OptimizerCatalog::new(
346 OptimizerCatalogIndexes::new(
347 equality_property_indexes,
348 composite_property_indexes,
349 range_property_indexes,
350 full_text_property_indexes,
351 ),
352 OptimizerCatalogStatistics::new(
353 label_counts,
354 rel_type_counts,
355 rel_type_source_counts,
356 path_counts,
357 bounded_path_counts,
358 property_distinct_counts,
359 property_histograms,
360 )
361 .with_property_index_statistics(property_index_statistics)
362 .with_composite_index_statistics(composite_index_statistics)
363 .with_relationship_type_target_counts(rel_type_target_counts)
364 .with_path_source_distinct_counts(path_source_distinct_counts)
365 .with_path_target_distinct_counts(path_target_distinct_counts)
366 .with_bounded_path_source_distinct_counts(bounded_path_source_distinct_counts)
367 .with_bounded_path_target_distinct_counts(bounded_path_target_distinct_counts)
368 .with_relationship_property_distinct_counts(rel_property_distinct_counts)
369 .with_relationship_property_histograms(rel_property_histograms)
370 .with_sampled_property_histograms(sampled_property_histograms)
371 .with_sampled_relationship_property_histograms(sampled_rel_property_histograms),
372 )
373}
374
375impl OptimizerCatalog {
376 pub fn new(indexes: OptimizerCatalogIndexes, statistics: OptimizerCatalogStatistics) -> Self {
377 Self {
378 assume_all_indexes: false,
379 equality_property_indexes: indexes.equality_property_indexes,
380 composite_property_indexes: indexes.composite_property_indexes,
381 range_property_indexes: indexes.range_property_indexes,
382 full_text_property_indexes: indexes.full_text_property_indexes,
383 label_counts: statistics.label_counts,
384 rel_type_counts: statistics.rel_type_counts,
385 rel_type_source_counts: statistics.rel_type_source_counts,
386 rel_type_target_counts: statistics.rel_type_target_counts,
387 path_counts: statistics.path_counts,
388 path_source_distinct_counts: statistics.path_source_distinct_counts,
389 path_target_distinct_counts: statistics.path_target_distinct_counts,
390 bounded_path_counts: statistics.bounded_path_counts,
391 bounded_path_source_distinct_counts: statistics.bounded_path_source_distinct_counts,
392 bounded_path_target_distinct_counts: statistics.bounded_path_target_distinct_counts,
393 property_index_statistics: statistics.property_index_statistics,
394 composite_index_statistics: statistics.composite_index_statistics,
395 property_distinct_counts: statistics.property_distinct_counts,
396 rel_property_distinct_counts: statistics.rel_property_distinct_counts,
397 property_histograms: statistics.property_histograms,
398 rel_property_histograms: statistics.rel_property_histograms,
399 sampled_property_histograms: statistics.sampled_property_histograms,
400 sampled_rel_property_histograms: statistics.sampled_rel_property_histograms,
401 }
402 }
403
404 pub(super) fn optimistic() -> Self {
405 Self {
406 assume_all_indexes: true,
407 ..Self::default()
408 }
409 }
410
411 pub(super) fn has_property_index(&self, label: &str, property: &str) -> bool {
412 self.assume_all_indexes
413 || self
414 .equality_property_indexes
415 .contains(&(label.to_string(), property.to_string()))
416 }
417
418 pub(super) fn has_range_property_index(&self, label: &str, property: &str) -> bool {
419 self.assume_all_indexes
420 || self
421 .range_property_indexes
422 .contains(&(label.to_string(), property.to_string()))
423 }
424
425 pub(super) fn has_full_text_property_index(&self, label: &str, property: &str) -> bool {
426 self.assume_all_indexes
427 || self
428 .full_text_property_indexes
429 .contains(&(label.to_string(), property.to_string()))
430 }
431
432 pub(super) fn has_composite_property_index(&self, label: &str, properties: &[String]) -> bool {
433 self.assume_all_indexes
434 || self
435 .composite_property_indexes
436 .contains(&(label.to_string(), properties.to_vec()))
437 }
438
439 pub(super) fn composite_property_indexes_for_label(&self, label: &str) -> Vec<Vec<String>> {
440 if self.assume_all_indexes {
441 return Vec::new();
442 }
443 self.composite_property_indexes
444 .iter()
445 .filter_map(|(candidate_label, properties)| {
446 if candidate_label == label {
447 Some(properties.clone())
448 } else {
449 None
450 }
451 })
452 .collect()
453 }
454
455 pub(super) fn label_count(&self, label: &str) -> u64 {
456 self.label_counts.get(label).copied().unwrap_or(1).max(1)
457 }
458
459 pub(super) fn relationship_count(&self, rel_type: &str) -> u64 {
460 self.rel_type_counts
461 .get(rel_type)
462 .copied()
463 .unwrap_or(1)
464 .max(1)
465 }
466
467 pub(super) fn path_source_distinct_count(
468 &self,
469 source_label: &str,
470 rel_type: &str,
471 target_label: &str,
472 ) -> Option<u64> {
473 self.path_source_distinct_counts
474 .get(&(
475 source_label.to_string(),
476 rel_type.to_string(),
477 target_label.to_string(),
478 ))
479 .copied()
480 }
481
482 pub(super) fn path_target_distinct_count(
483 &self,
484 source_label: &str,
485 rel_type: &str,
486 target_label: &str,
487 ) -> Option<u64> {
488 self.path_target_distinct_counts
489 .get(&(
490 source_label.to_string(),
491 rel_type.to_string(),
492 target_label.to_string(),
493 ))
494 .copied()
495 }
496
497 pub(super) fn bounded_path_source_distinct_count(
498 &self,
499 source_label: &str,
500 rel_type: &str,
501 target_label: &str,
502 min_hops: usize,
503 max_hops: usize,
504 ) -> Option<u64> {
505 self.bounded_path_distinct_count(
506 &self.bounded_path_source_distinct_counts,
507 source_label,
508 rel_type,
509 target_label,
510 min_hops,
511 max_hops,
512 )
513 }
514
515 pub(super) fn bounded_path_target_distinct_count(
516 &self,
517 source_label: &str,
518 rel_type: &str,
519 target_label: &str,
520 min_hops: usize,
521 max_hops: usize,
522 ) -> Option<u64> {
523 self.bounded_path_distinct_count(
524 &self.bounded_path_target_distinct_counts,
525 source_label,
526 rel_type,
527 target_label,
528 min_hops,
529 max_hops,
530 )
531 }
532
533 pub(super) fn bounded_path_distinct_count(
534 &self,
535 counts: &BTreeMap<(String, String, String, usize), u64>,
536 source_label: &str,
537 rel_type: &str,
538 target_label: &str,
539 min_hops: usize,
540 max_hops: usize,
541 ) -> Option<u64> {
542 let mut total = 0_u64;
543 let mut found = false;
544 for hop in min_hops..=max_hops.max(min_hops) {
545 if let Some(count) = counts.get(&(
546 source_label.to_string(),
547 rel_type.to_string(),
548 target_label.to_string(),
549 hop,
550 )) {
551 found = true;
552 total = total.saturating_add(*count);
553 }
554 }
555 found.then_some(total.max(1))
556 }
557
558 pub(super) fn distinct_count(&self, label: &str, property: &str) -> u64 {
559 self.property_index_statistics
560 .get(&(label.to_string(), property.to_string()))
561 .map(|statistics| statistics.distinct_count)
562 .or_else(|| {
563 self.property_distinct_counts
564 .get(&(label.to_string(), property.to_string()))
565 .copied()
566 })
567 .unwrap_or_else(|| self.label_count(label).max(1))
568 }
569
570 pub(super) fn estimate_property_index_eq_rows(&self, label: &str, property: &str) -> u64 {
571 self.property_index_statistics
572 .get(&(label.to_string(), property.to_string()))
573 .map(|statistics| {
574 statistics
575 .index_size
576 .div_ceil(statistics.distinct_count.max(1))
577 .min(self.label_count(label))
578 .max(1)
579 })
580 .unwrap_or_else(|| {
581 self.label_count(label)
582 .div_ceil(self.distinct_count(label, property).max(1))
583 .max(1)
584 })
585 }
586
587 pub(super) fn estimate_property_index_in_rows(
588 &self,
589 label: &str,
590 property: &str,
591 value_count: u64,
592 ) -> u64 {
593 let upper_bound = self
594 .property_index_statistics
595 .get(&(label.to_string(), property.to_string()))
596 .map_or_else(
597 || self.label_count(label),
598 |statistics| statistics.index_size.min(self.label_count(label)),
599 );
600 self.estimate_property_index_eq_rows(label, property)
601 .saturating_mul(value_count)
602 .min(upper_bound)
603 .max(1)
604 }
605
606 pub(super) fn composite_distinct_count(&self, label: &str, properties: &[String]) -> u64 {
607 self.composite_index_statistics
608 .get(&(label.to_string(), properties.to_vec()))
609 .map(|statistics| statistics.distinct_count)
610 .unwrap_or_else(|| {
611 properties
612 .iter()
613 .map(|property| self.distinct_count(label, property).max(1))
614 .fold(1_u64, |product, distinct| product.saturating_mul(distinct))
615 .max(1)
616 })
617 }
618
619 pub(super) fn estimate_composite_property_index_rows(
620 &self,
621 label: &str,
622 properties: &[String],
623 ) -> u64 {
624 self.composite_index_statistics
625 .get(&(label.to_string(), properties.to_vec()))
626 .map(|statistics| {
627 statistics
628 .index_size
629 .div_ceil(statistics.distinct_count.max(1))
630 .min(self.label_count(label))
631 .max(1)
632 })
633 .unwrap_or_else(|| {
634 self.label_count(label)
635 .div_ceil(self.composite_distinct_count(label, properties))
636 .max(1)
637 })
638 }
639
640 pub(super) fn estimate_composite_prefix_range_rows(
641 &self,
642 label: &str,
643 index_properties: &[String],
644 equality_properties: &[String],
645 range_property: &str,
646 lower: Option<&ValueRangeBound>,
647 upper: Option<&ValueRangeBound>,
648 ) -> u64 {
649 let label_count = self.label_count(label);
650 let equality_distinct = equality_properties
651 .iter()
652 .map(|property| self.distinct_count(label, property).max(1))
653 .fold(1u64, u64::saturating_mul);
654 let equality_rows = label_count.div_ceil(equality_distinct).max(1);
655 let range_rows = self.estimate_range_bounds_rows(label, range_property, lower, upper);
656 let estimated = equality_rows
657 .saturating_mul(range_rows)
658 .div_ceil(label_count)
659 .max(1);
660 self.composite_index_statistics
661 .get(&(label.to_string(), index_properties.to_vec()))
662 .map_or(estimated, |statistics| {
663 estimated.min(statistics.index_size.max(1))
664 })
665 }
666
667 pub(super) fn known_distinct_count(&self, label: &str, property: &str) -> Option<u64> {
668 self.property_index_statistics
669 .get(&(label.to_string(), property.to_string()))
670 .map(|statistics| statistics.distinct_count)
671 .or_else(|| {
672 self.property_distinct_counts
673 .get(&(label.to_string(), property.to_string()))
674 .copied()
675 })
676 }
677
678 pub(super) fn known_rel_property_distinct_count(
679 &self,
680 rel_type: &str,
681 property: &str,
682 ) -> Option<u64> {
683 self.rel_property_distinct_counts
684 .get(&(rel_type.to_string(), property.to_string()))
685 .copied()
686 }
687
688 pub(super) fn rel_property_distinct_count(&self, rel_type: &str, property: &str) -> u64 {
689 self.rel_property_distinct_counts
690 .get(&(rel_type.to_string(), property.to_string()))
691 .copied()
692 .unwrap_or_else(|| {
693 self.rel_type_counts
694 .get(rel_type)
695 .copied()
696 .unwrap_or(1)
697 .max(1)
698 })
699 }
700
701 pub(super) fn estimate_rel_property_eq_rows(
702 &self,
703 rel_type: &str,
704 property: &str,
705 input_rows: u64,
706 ) -> u64 {
707 input_rows
708 .div_ceil(self.rel_property_distinct_count(rel_type, property).max(1))
709 .max(1)
710 }
711
712 pub(super) fn estimate_rel_property_not_eq_rows(
713 &self,
714 rel_type: &str,
715 property: &str,
716 input_rows: u64,
717 ) -> u64 {
718 input_rows
719 .saturating_sub(self.estimate_rel_property_eq_rows(rel_type, property, input_rows))
720 }
721
722 pub(super) fn estimate_rel_property_in_rows(
723 &self,
724 rel_type: &str,
725 property: &str,
726 values: &[Value],
727 input_rows: u64,
728 ) -> u64 {
729 if values.is_empty() {
730 return 0;
731 }
732 let distinct_count = self.rel_property_distinct_count(rel_type, property).max(1);
733 let value_count = values
734 .iter()
735 .collect::<BTreeSet<_>>()
736 .len()
737 .min(distinct_count as usize) as u64;
738 input_rows
739 .saturating_mul(value_count)
740 .div_ceil(distinct_count)
741 .max(1)
742 }
743
744 pub(super) fn estimate_rel_property_string_match_rows(
745 &self,
746 rel_type: &str,
747 property: &str,
748 input_rows: u64,
749 max_divisor: u64,
750 ) -> u64 {
751 let divisor = self
752 .rel_property_distinct_count(rel_type, property)
753 .max(1)
754 .min(max_divisor)
755 .max(1);
756 input_rows.div_ceil(divisor).max(1)
757 }
758
759 pub(super) fn estimate_rel_property_null_rows(
760 &self,
761 rel_type: &str,
762 property: &str,
763 input_rows: u64,
764 ) -> u64 {
765 let distinct_count = self.rel_property_distinct_count(rel_type, property).max(1);
766 input_rows
767 .div_ceil(distinct_count.min(NULL_SELECTIVITY_DIVISOR_CAP))
768 .max(1)
769 }
770
771 pub(super) fn estimate_rel_property_not_null_rows(
772 &self,
773 rel_type: &str,
774 property: &str,
775 input_rows: u64,
776 ) -> u64 {
777 input_rows
778 .saturating_sub(self.estimate_rel_property_null_rows(rel_type, property, input_rows))
779 .max(1)
780 }
781
782 pub(super) fn estimate_rel_property_range_rows(
783 &self,
784 rel_type: &str,
785 property: &str,
786 op: ComparisonOp,
787 value: &Value,
788 input_rows: u64,
789 ) -> u64 {
790 let key = (rel_type.to_string(), property.to_string());
791 let Some(histogram) = self
792 .rel_property_histograms
793 .get(&key)
794 .filter(|values| !values.is_empty())
795 else {
796 return input_rows.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
797 };
798 let matching_values = histogram
799 .iter()
800 .filter(|candidate| compare_histogram_value(candidate, op, value))
801 .count() as u64;
802 estimate_histogram_rows(
803 input_rows,
804 matching_values,
805 histogram.len() as u64,
806 self.sampled_rel_property_histograms
807 .get(&key)
808 .copied()
809 .unwrap_or(false),
810 )
811 }
812
813 pub(super) fn estimate_range_rows(
814 &self,
815 label: &str,
816 property: &str,
817 op: ComparisonOp,
818 value: &Value,
819 ) -> u64 {
820 let label_count = self.label_count(label);
821 let key = (label.to_string(), property.to_string());
822 let Some(histogram) = self
823 .property_histograms
824 .get(&key)
825 .filter(|values| !values.is_empty())
826 else {
827 return label_count.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
828 };
829 let matching_values = histogram
830 .iter()
831 .filter(|candidate| compare_histogram_value(candidate, op, value))
832 .count() as u64;
833 estimate_histogram_rows(
834 label_count,
835 matching_values,
836 histogram.len() as u64,
837 self.sampled_property_histograms
838 .get(&key)
839 .copied()
840 .unwrap_or(false),
841 )
842 }
843
844 pub(super) fn estimate_property_eq_rows(
845 &self,
846 label: &str,
847 property: &str,
848 input_rows: u64,
849 ) -> u64 {
850 input_rows
851 .div_ceil(self.distinct_count(label, property).max(1))
852 .max(1)
853 }
854
855 pub(super) fn estimate_property_not_eq_rows(
856 &self,
857 label: &str,
858 property: &str,
859 input_rows: u64,
860 ) -> u64 {
861 input_rows.saturating_sub(self.estimate_property_eq_rows(label, property, input_rows))
862 }
863
864 pub(super) fn estimate_property_in_rows(
865 &self,
866 label: &str,
867 property: &str,
868 values: &[Value],
869 input_rows: u64,
870 ) -> u64 {
871 if values.is_empty() {
872 return 0;
873 }
874 let distinct_count = self.distinct_count(label, property).max(1);
875 let value_count = values
876 .iter()
877 .collect::<BTreeSet<_>>()
878 .len()
879 .min(distinct_count as usize) as u64;
880 input_rows
881 .saturating_mul(value_count)
882 .div_ceil(distinct_count)
883 .max(1)
884 }
885
886 pub(super) fn estimate_property_string_match_rows(
887 &self,
888 label: &str,
889 property: &str,
890 input_rows: u64,
891 max_divisor: u64,
892 ) -> u64 {
893 let divisor = self
894 .distinct_count(label, property)
895 .max(1)
896 .min(max_divisor)
897 .max(1);
898 input_rows.div_ceil(divisor).max(1)
899 }
900
901 pub(super) fn estimate_property_null_rows(
902 &self,
903 label: &str,
904 property: &str,
905 input_rows: u64,
906 ) -> u64 {
907 let distinct_count = self.distinct_count(label, property).max(1);
908 input_rows
909 .div_ceil(distinct_count.min(NULL_SELECTIVITY_DIVISOR_CAP))
910 .max(1)
911 }
912
913 pub(super) fn estimate_property_not_null_rows(
914 &self,
915 label: &str,
916 property: &str,
917 input_rows: u64,
918 ) -> u64 {
919 input_rows
920 .saturating_sub(self.estimate_property_null_rows(label, property, input_rows))
921 .max(1)
922 }
923
924 pub(super) fn estimate_property_range_rows(
925 &self,
926 label: &str,
927 property: &str,
928 op: ComparisonOp,
929 value: &Value,
930 input_rows: u64,
931 ) -> u64 {
932 let key = (label.to_string(), property.to_string());
933 let Some(histogram) = self
934 .property_histograms
935 .get(&key)
936 .filter(|values| !values.is_empty())
937 else {
938 return input_rows.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
939 };
940 let matching_values = histogram
941 .iter()
942 .filter(|candidate| compare_histogram_value(candidate, op, value))
943 .count() as u64;
944 estimate_histogram_rows(
945 input_rows,
946 matching_values,
947 histogram.len() as u64,
948 self.sampled_property_histograms
949 .get(&key)
950 .copied()
951 .unwrap_or(false),
952 )
953 }
954
955 pub(super) fn estimate_range_bounds_rows(
956 &self,
957 label: &str,
958 property: &str,
959 lower: Option<&ValueRangeBound>,
960 upper: Option<&ValueRangeBound>,
961 ) -> u64 {
962 let label_count = self.label_count(label);
963 let key = (label.to_string(), property.to_string());
964 let Some(histogram) = self
965 .property_histograms
966 .get(&key)
967 .filter(|values| !values.is_empty())
968 else {
969 return label_count.div_ceil(RANGE_SELECTIVITY_DIVISOR).max(1);
970 };
971 let matching_values = histogram
972 .iter()
973 .filter(|candidate| range_bound_matches(candidate, lower, upper))
974 .count() as u64;
975 estimate_histogram_rows(
976 label_count,
977 matching_values,
978 histogram.len() as u64,
979 self.sampled_property_histograms
980 .get(&key)
981 .copied()
982 .unwrap_or(false),
983 )
984 }
985
986 pub(super) fn estimate_expand_rows(
987 &self,
988 source_label: &str,
989 rel_type: &str,
990 rel_properties: &BTreeMap<String, Value>,
991 target_label: &str,
992 min_hops: usize,
993 max_hops: usize,
994 ) -> ExpandEstimate {
995 let path_count = self
996 .path_counts
997 .get(&(
998 source_label.to_string(),
999 rel_type.to_string(),
1000 target_label.to_string(),
1001 ))
1002 .copied();
1003 let rel_count = self.rel_type_counts.get(rel_type).copied().unwrap_or(1);
1004 let source_count = self
1005 .rel_type_source_counts
1006 .get(rel_type)
1007 .copied()
1008 .unwrap_or_else(|| self.label_count(source_label).max(1));
1009 let average_fanout = rel_count.div_ceil(source_count.max(1)).max(1);
1010 let one_hop = path_count.unwrap_or_else(|| {
1011 self.label_count(source_label)
1012 .min(self.label_count(target_label))
1013 .max(1)
1014 });
1015 let property_distinct_product = rel_properties
1016 .keys()
1017 .map(|property| self.rel_property_distinct_count(rel_type, property).max(1))
1018 .fold(1_u64, |acc, value| acc.saturating_mul(value))
1019 .max(1);
1020 let mut estimated_rows = 0_u64;
1021 let mut hop_rows = one_hop.max(1);
1022 let mut hop_estimates = Vec::new();
1023 for hop in 1..=max_hops.max(1) {
1024 let exact_hop_rows = self
1025 .bounded_path_counts
1026 .get(&(
1027 source_label.to_string(),
1028 rel_type.to_string(),
1029 target_label.to_string(),
1030 hop,
1031 ))
1032 .copied();
1033 let current_hop_rows = exact_hop_rows
1034 .unwrap_or(hop_rows)
1035 .div_ceil(property_distinct_product)
1036 .max(1);
1037 hop_estimates.push(HopEstimate {
1038 hop,
1039 rows: current_hop_rows,
1040 exact: exact_hop_rows.is_some(),
1041 });
1042 if hop >= min_hops {
1043 estimated_rows = estimated_rows.saturating_add(current_hop_rows);
1044 }
1045 hop_rows = current_hop_rows.max(1).saturating_mul(average_fanout);
1046 }
1047 ExpandEstimate {
1048 path_count,
1049 rel_count,
1050 source_count,
1051 average_fanout,
1052 property_distinct_product,
1053 estimated_rows: estimated_rows.max(1),
1054 hop_estimates,
1055 }
1056 }
1057}
1058
1059fn estimate_histogram_rows(
1060 input_rows: u64,
1061 matching_values: u64,
1062 histogram_values: u64,
1063 sampled: bool,
1064) -> u64 {
1065 let (matching_values, histogram_values) = if sampled {
1069 (
1070 matching_values.saturating_add(SAMPLED_HISTOGRAM_MATCH_PSEUDOCOUNT),
1071 histogram_values.saturating_add(SAMPLED_HISTOGRAM_TOTAL_PSEUDOCOUNT),
1072 )
1073 } else {
1074 (matching_values, histogram_values)
1075 };
1076 input_rows
1077 .saturating_mul(matching_values)
1078 .div_ceil(histogram_values.max(1))
1079 .max(1)
1080}
1081
1082impl OptimizerCatalogIndexes {
1083 pub fn new(
1084 equality_property_indexes: impl IntoIterator<Item = (String, String)>,
1085 composite_property_indexes: impl IntoIterator<Item = (String, Vec<String>)>,
1086 range_property_indexes: impl IntoIterator<Item = (String, String)>,
1087 full_text_property_indexes: impl IntoIterator<Item = (String, String)>,
1088 ) -> Self {
1089 Self {
1090 equality_property_indexes: equality_property_indexes.into_iter().collect(),
1091 composite_property_indexes: composite_property_indexes.into_iter().collect(),
1092 range_property_indexes: range_property_indexes.into_iter().collect(),
1093 full_text_property_indexes: full_text_property_indexes.into_iter().collect(),
1094 }
1095 }
1096}
1097
1098impl OptimizerCatalogStatistics {
1099 pub fn new(
1100 label_counts: impl IntoIterator<Item = (String, u64)>,
1101 rel_type_counts: impl IntoIterator<Item = (String, u64)>,
1102 rel_type_source_counts: impl IntoIterator<Item = (String, u64)>,
1103 path_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1104 bounded_path_counts: impl IntoIterator<Item = ((String, String, String, usize), u64)>,
1105 property_distinct_counts: impl IntoIterator<Item = ((String, String), u64)>,
1106 property_histograms: impl IntoIterator<Item = ((String, String), Vec<Value>)>,
1107 ) -> Self {
1108 Self {
1109 label_counts: label_counts.into_iter().collect(),
1110 rel_type_counts: rel_type_counts.into_iter().collect(),
1111 rel_type_source_counts: rel_type_source_counts.into_iter().collect(),
1112 rel_type_target_counts: BTreeMap::new(),
1113 path_counts: path_counts.into_iter().collect(),
1114 path_source_distinct_counts: BTreeMap::new(),
1115 path_target_distinct_counts: BTreeMap::new(),
1116 bounded_path_counts: bounded_path_counts.into_iter().collect(),
1117 bounded_path_source_distinct_counts: BTreeMap::new(),
1118 bounded_path_target_distinct_counts: BTreeMap::new(),
1119 property_index_statistics: BTreeMap::new(),
1120 composite_index_statistics: BTreeMap::new(),
1121 property_distinct_counts: property_distinct_counts.into_iter().collect(),
1122 rel_property_distinct_counts: BTreeMap::new(),
1123 property_histograms: property_histograms.into_iter().collect(),
1124 rel_property_histograms: BTreeMap::new(),
1125 sampled_property_histograms: BTreeMap::new(),
1126 sampled_rel_property_histograms: BTreeMap::new(),
1127 }
1128 }
1129
1130 pub fn with_property_index_statistics(
1131 mut self,
1132 statistics: impl IntoIterator<Item = ((String, String), OptimizerIndexStatistics)>,
1133 ) -> Self {
1134 self.property_index_statistics = statistics.into_iter().collect();
1135 self
1136 }
1137
1138 pub fn with_composite_index_statistics(
1139 mut self,
1140 statistics: impl IntoIterator<Item = ((String, Vec<String>), OptimizerIndexStatistics)>,
1141 ) -> Self {
1142 self.composite_index_statistics = statistics.into_iter().collect();
1143 self
1144 }
1145
1146 pub fn with_relationship_property_distinct_counts(
1147 mut self,
1148 rel_property_distinct_counts: impl IntoIterator<Item = ((String, String), u64)>,
1149 ) -> Self {
1150 self.rel_property_distinct_counts = rel_property_distinct_counts.into_iter().collect();
1151 self
1152 }
1153
1154 pub fn with_relationship_type_target_counts(
1155 mut self,
1156 rel_type_target_counts: impl IntoIterator<Item = (String, u64)>,
1157 ) -> Self {
1158 self.rel_type_target_counts = rel_type_target_counts.into_iter().collect();
1159 self
1160 }
1161
1162 pub fn with_path_source_distinct_counts(
1163 mut self,
1164 path_source_distinct_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1165 ) -> Self {
1166 self.path_source_distinct_counts = path_source_distinct_counts.into_iter().collect();
1167 self
1168 }
1169
1170 pub fn with_path_target_distinct_counts(
1171 mut self,
1172 path_target_distinct_counts: impl IntoIterator<Item = ((String, String, String), u64)>,
1173 ) -> Self {
1174 self.path_target_distinct_counts = path_target_distinct_counts.into_iter().collect();
1175 self
1176 }
1177
1178 pub fn with_bounded_path_source_distinct_counts(
1179 mut self,
1180 bounded_path_source_distinct_counts: impl IntoIterator<
1181 Item = ((String, String, String, usize), u64),
1182 >,
1183 ) -> Self {
1184 self.bounded_path_source_distinct_counts =
1185 bounded_path_source_distinct_counts.into_iter().collect();
1186 self
1187 }
1188
1189 pub fn with_bounded_path_target_distinct_counts(
1190 mut self,
1191 bounded_path_target_distinct_counts: impl IntoIterator<
1192 Item = ((String, String, String, usize), u64),
1193 >,
1194 ) -> Self {
1195 self.bounded_path_target_distinct_counts =
1196 bounded_path_target_distinct_counts.into_iter().collect();
1197 self
1198 }
1199
1200 pub fn with_relationship_property_histograms(
1201 mut self,
1202 rel_property_histograms: impl IntoIterator<Item = ((String, String), Vec<Value>)>,
1203 ) -> Self {
1204 self.rel_property_histograms = rel_property_histograms.into_iter().collect();
1205 self
1206 }
1207
1208 pub fn with_sampled_property_histograms(
1209 mut self,
1210 sampled_property_histograms: impl IntoIterator<Item = ((String, String), bool)>,
1211 ) -> Self {
1212 self.sampled_property_histograms = sampled_property_histograms.into_iter().collect();
1213 self
1214 }
1215
1216 pub fn with_sampled_relationship_property_histograms(
1217 mut self,
1218 sampled_rel_property_histograms: impl IntoIterator<Item = ((String, String), bool)>,
1219 ) -> Self {
1220 self.sampled_rel_property_histograms =
1221 sampled_rel_property_histograms.into_iter().collect();
1222 self
1223 }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229
1230 #[test]
1231 fn graph_statistics_catalog_keeps_basic_counts_when_advanced_stats_are_incomplete() {
1232 let mut source_catalog = Catalog::default();
1233 let memory = source_catalog.get_or_create_label("Memory");
1234 let mut statistics = GraphStatistics {
1235 advanced_statistics_complete: false,
1236 label_counts: BTreeMap::from([(memory, 64)]),
1237 property_distinct_counts: BTreeMap::from([((memory, "kind".to_string()), 4)]),
1238 property_histograms: BTreeMap::from([(
1239 (memory, "kind".to_string()),
1240 vec![Value::String("note".to_string())],
1241 )]),
1242 ..GraphStatistics::default()
1243 };
1244
1245 let incomplete = optimizer_catalog_from_graph_statistics(&source_catalog, &statistics);
1246 assert_eq!(incomplete.label_count("Memory"), 64);
1247 assert_eq!(incomplete.known_distinct_count("Memory", "kind"), None);
1248 assert!(incomplete.property_histograms.is_empty());
1249
1250 statistics.advanced_statistics_complete = true;
1251 let complete = optimizer_catalog_from_graph_statistics(&source_catalog, &statistics);
1252 assert_eq!(complete.label_count("Memory"), 64);
1253 assert_eq!(complete.known_distinct_count("Memory", "kind"), Some(4));
1254 assert_eq!(
1255 complete.property_histograms,
1256 BTreeMap::from([(
1257 ("Memory".to_string(), "kind".to_string()),
1258 vec![Value::String("note".to_string())],
1259 )])
1260 );
1261 }
1262
1263 #[test]
1264 fn range_cardinality_honors_half_open_histogram_bounds() {
1265 let catalog = OptimizerCatalog::new(
1266 OptimizerCatalogIndexes::default(),
1267 OptimizerCatalogStatistics::new(
1268 [("Memory".to_string(), 1_000)],
1269 [],
1270 [],
1271 [],
1272 [],
1273 [],
1274 [(
1275 ("Memory".to_string(), "created_at".to_string()),
1276 (0..100).map(Value::Int).collect::<Vec<_>>(),
1277 )],
1278 ),
1279 );
1280
1281 assert_eq!(
1282 catalog.estimate_range_bounds_rows(
1283 "Memory",
1284 "created_at",
1285 Some(&(Value::Int(10), true)),
1286 Some(&(Value::Int(20), false)),
1287 ),
1288 100
1289 );
1290 }
1291
1292 #[test]
1293 fn sampled_histograms_shrink_range_selectivity_toward_default() {
1294 let property_key = ("Memory".to_string(), "score".to_string());
1295 let rel_property_key = ("MENTIONS".to_string(), "score".to_string());
1296 let histogram = (0..100).map(Value::Int).collect::<Vec<_>>();
1297 let statistics = OptimizerCatalogStatistics::new(
1298 [("Memory".to_string(), 1_000)],
1299 [],
1300 [],
1301 [],
1302 [],
1303 [(property_key.clone(), 100)],
1304 [(property_key.clone(), histogram.clone())],
1305 )
1306 .with_relationship_property_histograms([(rel_property_key.clone(), histogram)]);
1307 let exhaustive =
1308 OptimizerCatalog::new(OptimizerCatalogIndexes::default(), statistics.clone());
1309 let sampled = OptimizerCatalog::new(
1310 OptimizerCatalogIndexes::default(),
1311 statistics
1312 .with_sampled_property_histograms([(property_key, true)])
1313 .with_sampled_relationship_property_histograms([(rel_property_key, true)]),
1314 );
1315
1316 assert_eq!(
1317 exhaustive.estimate_range_rows("Memory", "score", ComparisonOp::Lt, &Value::Int(10)),
1318 100
1319 );
1320 assert_eq!(
1321 sampled.estimate_range_rows("Memory", "score", ComparisonOp::Lt, &Value::Int(10)),
1322 108
1323 );
1324 assert_eq!(
1325 sampled.estimate_property_range_rows(
1326 "Memory",
1327 "score",
1328 ComparisonOp::Lt,
1329 &Value::Int(10),
1330 1_000,
1331 ),
1332 108
1333 );
1334 assert_eq!(
1335 sampled.estimate_range_bounds_rows(
1336 "Memory",
1337 "score",
1338 None,
1339 Some(&(Value::Int(10), false)),
1340 ),
1341 108
1342 );
1343 assert_eq!(
1344 exhaustive.estimate_rel_property_range_rows(
1345 "MENTIONS",
1346 "score",
1347 ComparisonOp::Lt,
1348 &Value::Int(10),
1349 1_000,
1350 ),
1351 100
1352 );
1353 assert_eq!(
1354 sampled.estimate_rel_property_range_rows(
1355 "MENTIONS",
1356 "score",
1357 ComparisonOp::Lt,
1358 &Value::Int(10),
1359 1_000,
1360 ),
1361 108
1362 );
1363 }
1364
1365 #[test]
1366 fn index_cardinality_uses_sparse_index_size_and_joint_ndv() {
1367 let statistics = OptimizerCatalogStatistics {
1368 label_counts: BTreeMap::from([("Memory".to_string(), 100)]),
1369 property_index_statistics: BTreeMap::from([(
1370 ("Memory".to_string(), "kind".to_string()),
1371 OptimizerIndexStatistics {
1372 index_size: 10,
1373 distinct_count: 2,
1374 },
1375 )]),
1376 composite_index_statistics: BTreeMap::from([(
1377 (
1378 "Memory".to_string(),
1379 vec!["kind".to_string(), "source_id".to_string()],
1380 ),
1381 OptimizerIndexStatistics {
1382 index_size: 8,
1383 distinct_count: 4,
1384 },
1385 )]),
1386 ..OptimizerCatalogStatistics::default()
1387 };
1388 let catalog = OptimizerCatalog::new(OptimizerCatalogIndexes::default(), statistics);
1389
1390 assert_eq!(catalog.estimate_property_index_eq_rows("Memory", "kind"), 5);
1391 assert_eq!(
1392 catalog.estimate_property_index_in_rows("Memory", "kind", 10),
1393 10
1394 );
1395 assert_eq!(
1396 catalog.estimate_composite_property_index_rows(
1397 "Memory",
1398 &["kind".to_string(), "source_id".to_string()],
1399 ),
1400 2
1401 );
1402 }
1403}