1use nodedb_types::NodeDbError;
11use nodedb_types::collection_config::VectorPrimaryConfig;
12use nodedb_types::vector_ann::VectorQuantization;
13use nodedb_types::vector_distance::DistanceMetric;
14use nodedb_types::vector_dtype::VectorStorageDtype;
15
16use crate::parser::preprocess::lex::{
17 find_ascii_case_insensitive, find_ascii_case_insensitive_from, find_ascii_keyword,
18};
19
20const VALID_QUANTIZATIONS: &[&str] = &[
22 "none", "sq8", "pq", "rabitq", "bbq", "binary", "ternary", "opq",
23];
24
25pub fn parse_vector_primary_options(sql: &str) -> Result<Option<VectorPrimaryConfig>, NodeDbError> {
32 let primary_val = extract_with_str(sql, "primary");
33
34 match primary_val.as_deref() {
35 None
36 | Some("document_schemaless")
37 | Some("document_strict")
38 | Some("kv")
39 | Some("columnar")
40 | Some("timeseries")
41 | Some("spatial") => return Ok(None),
42 Some("vector") => {}
43 Some(other) => {
44 return Err(NodeDbError::bad_request(format!(
45 "unknown primary engine '{other}'; valid values: \
46 document_schemaless, document_strict, kv, columnar, timeseries, spatial, vector"
47 )));
48 }
49 }
50
51 let vector_field = extract_with_str(sql, "vector_field")
53 .ok_or_else(|| NodeDbError::bad_request("primary='vector' requires vector_field option"))?;
54 if vector_field.is_empty() {
55 return Err(NodeDbError::bad_request(
56 "vector_field must be a non-empty column name",
57 ));
58 }
59
60 let dim = extract_with_u32(sql, "dim").ok_or_else(|| {
62 NodeDbError::bad_request("primary='vector' requires dim option (e.g. dim=1024)")
63 })?;
64
65 let quantization = match extract_with_str(sql, "quantization").as_deref() {
67 None => VectorQuantization::default(),
68 Some(q) => parse_quantization(q)?,
69 };
70
71 let m: u8 = extract_with_u32(sql, "m")
73 .and_then(|v| u8::try_from(v).ok())
74 .unwrap_or(16);
75
76 let ef_construction: u16 = extract_with_u32(sql, "ef_construction")
78 .and_then(|v| u16::try_from(v).ok())
79 .unwrap_or(200);
80
81 let metric = match extract_with_str(sql, "metric").as_deref() {
83 None => DistanceMetric::Cosine,
84 Some(m) => parse_metric(m)?,
85 };
86
87 let storage_dtype = parse_storage_dtype(extract_with_str(sql, "storage_dtype").as_deref())?;
89
90 let payload_indexes = extract_payload_indexes(sql)
94 .into_iter()
95 .map(|f| (f, nodedb_types::PayloadIndexKind::Equality))
96 .collect();
97
98 Ok(Some(VectorPrimaryConfig {
99 vector_field,
100 dim,
101 quantization,
102 m,
103 ef_construction,
104 metric,
105 storage_dtype,
106 payload_indexes,
107 }))
108}
109
110pub fn validate_vector_field(
116 cfg: &VectorPrimaryConfig,
117 columns: &[(String, String)],
118) -> Result<(), NodeDbError> {
119 let col = columns
120 .iter()
121 .find(|(name, _)| name.eq_ignore_ascii_case(&cfg.vector_field));
122
123 let (_, type_str) = col.ok_or_else(|| {
124 NodeDbError::bad_request(format!(
125 "vector_field '{}' does not exist in the collection's column list",
126 cfg.vector_field
127 ))
128 })?;
129
130 if !type_str.to_uppercase().starts_with("VECTOR") {
131 return Err(NodeDbError::bad_request(format!(
132 "vector_field '{}' is of type '{}'; must be VECTOR(n)",
133 cfg.vector_field, type_str
134 )));
135 }
136
137 Ok(())
138}
139
140fn infer_payload_kind(upper_type: &str) -> nodedb_types::PayloadIndexKind {
142 use nodedb_types::PayloadIndexKind as K;
143 let head = upper_type
144 .split_once('(')
145 .map(|(p, _)| p)
146 .unwrap_or(upper_type)
147 .trim();
148 match head {
149 "BIGINT" | "INT" | "INTEGER" | "SMALLINT" | "TINYINT" | "BIGSERIAL" | "SERIAL"
150 | "FLOAT" | "DOUBLE" | "REAL" | "NUMERIC" | "DECIMAL" | "TIMESTAMP" | "TIMESTAMPTZ"
151 | "DATE" | "TIME" | "INSTANT" | "DATETIME" => K::Range,
152 "BOOL" | "BOOLEAN" => K::Boolean,
153 _ => K::Equality,
154 }
155}
156
157pub fn validate_payload_indexes(
163 cfg: &mut VectorPrimaryConfig,
164 columns: &[(String, String)],
165) -> Result<(), NodeDbError> {
166 for slot in cfg.payload_indexes.iter_mut() {
167 let field = slot.0.clone();
168 let col = columns
169 .iter()
170 .find(|(name, _)| name.eq_ignore_ascii_case(&field));
171
172 let (_, type_str) = col.ok_or_else(|| {
173 NodeDbError::bad_request(format!(
174 "payload_indexes field '{field}' does not exist in the collection's column list"
175 ))
176 })?;
177
178 let upper_type = type_str.to_uppercase();
179 if upper_type.starts_with("VECTOR")
180 || upper_type == "BLOB"
181 || upper_type == "BYTES"
182 || upper_type == "BYTEA"
183 {
184 return Err(NodeDbError::bad_request(format!(
185 "payload_indexes field '{field}' has type '{type_str}' which is not bitmap-eligible; \
186 only text, integer, boolean, and timestamp types are supported"
187 )));
188 }
189 slot.1 = infer_payload_kind(&upper_type);
190 }
191 Ok(())
192}
193
194pub fn parse_vector_primary_options_from_kvs(
201 options: &[(String, String)],
202) -> Result<Option<VectorPrimaryConfig>, NodeDbError> {
203 let get = |key: &str| -> Option<String> {
204 options
205 .iter()
206 .find(|(k, _)| k.eq_ignore_ascii_case(key))
207 .map(|(_, v)| v.clone())
208 };
209
210 let primary_val = get("primary");
211 match primary_val.as_deref() {
212 None
213 | Some("document_schemaless")
214 | Some("document_strict")
215 | Some("kv")
216 | Some("columnar")
217 | Some("timeseries")
218 | Some("spatial") => return Ok(None),
219 Some("vector") => {}
220 Some(other) => {
221 return Err(NodeDbError::bad_request(format!(
222 "unknown primary engine '{other}'; valid values: \
223 document_schemaless, document_strict, kv, columnar, timeseries, spatial, vector"
224 )));
225 }
226 }
227
228 let vector_field = get("vector_field")
229 .ok_or_else(|| NodeDbError::bad_request("primary='vector' requires vector_field option"))?;
230 if vector_field.is_empty() {
231 return Err(NodeDbError::bad_request(
232 "vector_field must be a non-empty column name",
233 ));
234 }
235
236 let dim = get("dim")
237 .and_then(|v| v.parse::<u32>().ok())
238 .ok_or_else(|| {
239 NodeDbError::bad_request("primary='vector' requires dim option (e.g. dim=1024)")
240 })?;
241
242 let quantization = match get("quantization").as_deref() {
243 None => VectorQuantization::default(),
244 Some(q) => parse_quantization(q)?,
245 };
246
247 let m: u8 = get("m")
248 .and_then(|v| v.parse::<u32>().ok())
249 .and_then(|v| u8::try_from(v).ok())
250 .unwrap_or(16);
251
252 let ef_construction: u16 = get("ef_construction")
253 .and_then(|v| v.parse::<u32>().ok())
254 .and_then(|v| u16::try_from(v).ok())
255 .unwrap_or(200);
256
257 let metric = match get("metric").as_deref() {
258 None => DistanceMetric::Cosine,
259 Some(m) => parse_metric(m)?,
260 };
261
262 let storage_dtype = parse_storage_dtype(get("storage_dtype").as_deref())?;
263
264 let payload_indexes = get("payload_indexes")
267 .map(|v| {
268 v.split(',')
269 .filter_map(|s| {
270 let s = s
271 .trim()
272 .trim_matches('\'')
273 .trim_matches('"')
274 .trim()
275 .to_lowercase();
276 if s.is_empty() {
277 None
278 } else {
279 Some((s, nodedb_types::PayloadIndexKind::Equality))
280 }
281 })
282 .collect::<Vec<_>>()
283 })
284 .unwrap_or_default();
285
286 Ok(Some(VectorPrimaryConfig {
287 vector_field,
288 dim,
289 quantization,
290 m,
291 ef_construction,
292 metric,
293 storage_dtype,
294 payload_indexes,
295 }))
296}
297
298fn with_clause(sql: &str) -> &str {
303 let Some(pos) = find_ascii_keyword(sql, "WITH") else {
304 return sql;
305 };
306 if pos > 0 {
308 let before = sql.as_bytes()[pos - 1];
309 if before.is_ascii_alphanumeric() || before == b'_' {
310 return sql;
311 }
312 }
313 let after = &sql[pos + 4..];
314 let Some(open) = after.find('(') else {
315 return sql;
316 };
317 let inner = &after[open + 1..];
318 let Some(close) = inner.rfind(')') else {
319 return inner;
320 };
321 &inner[..close]
322}
323
324fn extract_with_str(sql: &str, key: &str) -> Option<String> {
326 let scope = with_clause(sql);
327 let mut start = 0usize;
330 let pos = loop {
331 let abs = find_ascii_case_insensitive_from(scope, key, start)?;
332 let before_ok = abs == 0 || {
333 let b = scope.as_bytes()[abs - 1];
334 !(b.is_ascii_alphanumeric() || b == b'_')
335 };
336 let after_byte = scope
337 .as_bytes()
338 .get(abs + key.len())
339 .copied()
340 .unwrap_or(b' ');
341 let after_ok = !(after_byte.is_ascii_alphanumeric() || after_byte == b'_');
342 if before_ok && after_ok {
343 break abs;
344 }
345 start = abs + key.len();
346 };
347
348 let after = scope[pos + key.len()..].trim_start();
349 let after = after.strip_prefix('=')?;
350 let after = after.trim_start();
351
352 if let Some(rest) = after.strip_prefix('\'') {
354 let end = rest.find('\'')?;
355 let v = rest[..end].trim().to_lowercase();
356 return if v.is_empty() { None } else { Some(v) };
357 }
358 if let Some(rest) = after.strip_prefix('"') {
359 let end = rest.find('"')?;
360 let v = rest[..end].trim().to_lowercase();
361 return if v.is_empty() { None } else { Some(v) };
362 }
363
364 let end = after
366 .find(|c: char| c == ',' || c == ')' || c.is_whitespace())
367 .unwrap_or(after.len());
368 let v = after[..end].trim().to_lowercase();
369 if v.is_empty() { None } else { Some(v) }
370}
371
372fn extract_with_u32(sql: &str, key: &str) -> Option<u32> {
374 let raw = extract_with_str(sql, key)?;
375 raw.parse::<u32>().ok()
376}
377
378fn extract_payload_indexes(sql: &str) -> Vec<String> {
382 let scope = with_clause(sql);
383 let pos = match find_ascii_case_insensitive(scope, "PAYLOAD_INDEXES") {
384 Some(p) => p,
385 None => return Vec::new(),
386 };
387
388 let after = scope[pos + "payload_indexes".len()..].trim_start();
389 let after = match after.strip_prefix('=') {
390 Some(a) => a.trim_start(),
391 None => return Vec::new(),
392 };
393
394 let after = match after.strip_prefix('[') {
396 Some(a) => a,
397 None => return Vec::new(),
398 };
399 let end = match after.find(']') {
400 Some(e) => e,
401 None => return Vec::new(),
402 };
403 let inner = &after[..end];
404
405 inner
407 .split(',')
408 .filter_map(|s| {
409 let s = s.trim();
410 let s = s
411 .strip_prefix('\'')
412 .and_then(|s| s.strip_suffix('\''))
413 .or_else(|| s.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
414 .unwrap_or(s);
415 let s = s.trim().to_lowercase();
416 if s.is_empty() { None } else { Some(s) }
417 })
418 .collect()
419}
420
421fn parse_quantization(q: &str) -> Result<VectorQuantization, NodeDbError> {
423 match q.to_lowercase().as_str() {
424 "none" => Ok(VectorQuantization::None),
425 "sq8" => Ok(VectorQuantization::Sq8),
426 "pq" => Ok(VectorQuantization::Pq),
427 "rabitq" => Ok(VectorQuantization::RaBitQ),
428 "bbq" => Ok(VectorQuantization::Bbq),
429 "binary" => Ok(VectorQuantization::Binary),
430 "ternary" => Ok(VectorQuantization::Ternary),
431 "opq" => Ok(VectorQuantization::Opq),
432 other => Err(NodeDbError::bad_request(format!(
433 "unknown quantization '{other}'; valid values: {}",
434 VALID_QUANTIZATIONS.join(", ")
435 ))),
436 }
437}
438
439fn parse_storage_dtype(s: Option<&str>) -> Result<VectorStorageDtype, NodeDbError> {
443 let Some(s) = s else {
444 return Ok(VectorStorageDtype::default());
445 };
446 VectorStorageDtype::parse(s).ok_or_else(|| {
447 NodeDbError::bad_request(format!(
448 "unknown storage_dtype '{s}'; valid values: f32, f16, bf16"
449 ))
450 })
451}
452
453fn parse_metric(m: &str) -> Result<DistanceMetric, NodeDbError> {
455 match m.to_lowercase().as_str() {
456 "l2" | "euclidean" => Ok(DistanceMetric::L2),
457 "cosine" => Ok(DistanceMetric::Cosine),
458 "ip" | "inner_product" | "innerproduct" | "dot" => Ok(DistanceMetric::InnerProduct),
459 "manhattan" | "l1" => Ok(DistanceMetric::Manhattan),
460 "chebyshev" | "linf" | "l_inf" => Ok(DistanceMetric::Chebyshev),
461 "hamming" => Ok(DistanceMetric::Hamming),
462 "jaccard" => Ok(DistanceMetric::Jaccard),
463 "pearson" => Ok(DistanceMetric::Pearson),
464 other => Err(NodeDbError::bad_request(format!(
465 "unknown distance metric '{other}'; valid values: l2, cosine, ip, manhattan, \
466 chebyshev, hamming, jaccard, pearson"
467 ))),
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn with_clause_after_unicode_schema_preserves_original_offsets() {
477 assert_eq!(
478 with_clause("CREATE COLLECTION v (name ffff) WITH (primary='vector')"),
479 "primary='vector'"
480 );
481 }
482
483 #[test]
484 fn option_after_unicode_value_preserves_original_offsets() {
485 assert_eq!(
486 extract_with_str("WITH (note='ffff', metric='cosine')", "metric"),
487 Some("cosine".to_string())
488 );
489 }
490
491 #[test]
492 fn payload_indexes_after_unicode_value_preserve_original_offsets() {
493 assert_eq!(
494 extract_payload_indexes("WITH (note='ffff', payload_indexes=['category'])"),
495 vec!["category"]
496 );
497 }
498
499 #[test]
502 fn happy_path_full_options() {
503 let sql = "CREATE COLLECTION embeds \
504 (id BIGINT PRIMARY KEY, vec VECTOR(1024), category TEXT) \
505 WITH (primary='vector', vector_field='vec', dim=1024, \
506 quantization='rabitq', m=32, ef_construction=200, \
507 metric='cosine', payload_indexes=['category'])";
508 let cfg = parse_vector_primary_options(sql)
509 .expect("parse ok")
510 .expect("should be Some");
511 assert_eq!(cfg.vector_field, "vec");
512 assert_eq!(cfg.dim, 1024);
513 assert_eq!(cfg.quantization, VectorQuantization::RaBitQ);
514 assert_eq!(cfg.m, 32);
515 assert_eq!(cfg.ef_construction, 200);
516 assert_eq!(cfg.metric, DistanceMetric::Cosine);
517 assert_eq!(
518 cfg.payload_indexes,
519 vec![(
520 "category".to_string(),
521 nodedb_types::PayloadIndexKind::Equality
522 )]
523 );
524 }
525
526 #[test]
527 fn happy_path_minimal_options() {
528 let sql = "CREATE COLLECTION v (id BIGINT PRIMARY KEY, vec VECTOR(128)) \
529 WITH (primary='vector', vector_field='vec', dim=128)";
530 let cfg = parse_vector_primary_options(sql)
531 .expect("parse ok")
532 .expect("should be Some");
533 assert_eq!(cfg.vector_field, "vec");
534 assert_eq!(cfg.dim, 128);
535 assert_eq!(cfg.m, 16);
536 assert_eq!(cfg.ef_construction, 200);
537 assert_eq!(cfg.metric, DistanceMetric::Cosine);
538 assert!(cfg.payload_indexes.is_empty());
539 }
540
541 #[test]
542 fn happy_path_multiple_payload_indexes() {
543 let sql = "CREATE COLLECTION v (id BIGINT PRIMARY KEY, vec VECTOR(128), a TEXT, b INT) \
544 WITH (primary='vector', vector_field='vec', dim=128, \
545 payload_indexes=['a', 'b'])";
546 let cfg = parse_vector_primary_options(sql)
547 .expect("parse ok")
548 .expect("should be Some");
549 use nodedb_types::PayloadIndexKind as K;
550 assert_eq!(
551 cfg.payload_indexes,
552 vec![
553 ("a".to_string(), K::Equality),
554 ("b".to_string(), K::Equality)
555 ]
556 );
557 }
558
559 #[test]
562 fn no_primary_returns_none() {
563 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY)";
564 let result = parse_vector_primary_options(sql).expect("parse ok");
565 assert!(result.is_none());
566 }
567
568 #[test]
569 fn primary_document_returns_none() {
570 let sql =
571 "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='document_schemaless')";
572 let result = parse_vector_primary_options(sql).expect("parse ok");
573 assert!(result.is_none());
574 }
575
576 #[test]
577 fn primary_strict_returns_none() {
578 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='document_strict')";
579 let result = parse_vector_primary_options(sql).expect("parse ok");
580 assert!(result.is_none());
581 }
582
583 #[test]
584 fn primary_columnar_returns_none() {
585 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='columnar')";
586 let result = parse_vector_primary_options(sql).expect("parse ok");
587 assert!(result.is_none());
588 }
589
590 #[test]
593 fn missing_vector_field_returns_error() {
594 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
595 WITH (primary='vector', dim=64)";
596 let err = parse_vector_primary_options(sql).expect_err("should error");
597 let msg = format!("{err}");
598 assert!(
599 msg.contains("vector_field"),
600 "expected vector_field in error: {msg}"
601 );
602 }
603
604 #[test]
605 fn missing_dim_returns_error() {
606 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
607 WITH (primary='vector', vector_field='v')";
608 let err = parse_vector_primary_options(sql).expect_err("should error");
609 let msg = format!("{err}");
610 assert!(msg.contains("dim"), "expected dim in error: {msg}");
611 }
612
613 #[test]
616 fn unknown_quantization_returns_error() {
617 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
618 WITH (primary='vector', vector_field='v', dim=64, quantization='ivfflat')";
619 let err = parse_vector_primary_options(sql).expect_err("should error");
620 let msg = format!("{err}");
621 assert!(
622 msg.contains("ivfflat"),
623 "expected codec name in error: {msg}"
624 );
625 }
626
627 #[test]
630 fn all_valid_quantizations_accepted() {
631 for q in VALID_QUANTIZATIONS {
632 let sql = format!(
633 "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
634 WITH (primary='vector', vector_field='v', dim=64, quantization='{q}')"
635 );
636 let result = parse_vector_primary_options(&sql);
637 assert!(
638 result.is_ok(),
639 "quantization '{q}' should be accepted, got: {result:?}"
640 );
641 }
642 }
643
644 #[test]
647 fn validate_vector_field_ok() {
648 let cfg = VectorPrimaryConfig {
649 vector_field: "vec".to_string(),
650 dim: 128,
651 ..VectorPrimaryConfig::default()
652 };
653 let cols = vec![
654 ("id".to_string(), "BIGINT".to_string()),
655 ("vec".to_string(), "VECTOR(128)".to_string()),
656 ];
657 validate_vector_field(&cfg, &cols).expect("should be ok");
658 }
659
660 #[test]
661 fn validate_vector_field_nonexistent_column_errors() {
662 let cfg = VectorPrimaryConfig {
663 vector_field: "missing".to_string(),
664 dim: 128,
665 ..VectorPrimaryConfig::default()
666 };
667 let cols = vec![("id".to_string(), "BIGINT".to_string())];
668 let err = validate_vector_field(&cfg, &cols).expect_err("should error");
669 let msg = format!("{err}");
670 assert!(
671 msg.contains("missing"),
672 "expected column name in error: {msg}"
673 );
674 }
675
676 #[test]
677 fn validate_vector_field_wrong_type_errors() {
678 let cfg = VectorPrimaryConfig {
679 vector_field: "name".to_string(),
680 dim: 128,
681 ..VectorPrimaryConfig::default()
682 };
683 let cols = vec![("name".to_string(), "TEXT".to_string())];
684 let err = validate_vector_field(&cfg, &cols).expect_err("should error");
685 let msg = format!("{err}");
686 assert!(
687 msg.contains("VECTOR"),
688 "expected VECTOR mention in error: {msg}"
689 );
690 }
691
692 #[test]
695 fn validate_payload_indexes_ok() {
696 let mut cfg = VectorPrimaryConfig {
697 vector_field: "vec".to_string(),
698 dim: 128,
699 payload_indexes: vec![(
700 "category".to_string(),
701 nodedb_types::PayloadIndexKind::Equality,
702 )],
703 ..VectorPrimaryConfig::default()
704 };
705 let cols = vec![
706 ("vec".to_string(), "VECTOR(128)".to_string()),
707 ("category".to_string(), "TEXT".to_string()),
708 ];
709 validate_payload_indexes(&mut cfg, &cols).expect("should be ok");
710 }
711
712 #[test]
713 fn validate_payload_indexes_nonexistent_errors() {
714 let mut cfg = VectorPrimaryConfig {
715 vector_field: "vec".to_string(),
716 dim: 128,
717 payload_indexes: vec![(
718 "ghost".to_string(),
719 nodedb_types::PayloadIndexKind::Equality,
720 )],
721 ..VectorPrimaryConfig::default()
722 };
723 let cols = vec![("vec".to_string(), "VECTOR(128)".to_string())];
724 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
725 let msg = format!("{err}");
726 assert!(msg.contains("ghost"), "expected field name in error: {msg}");
727 }
728
729 #[test]
730 fn validate_payload_indexes_vector_type_rejected() {
731 let mut cfg = VectorPrimaryConfig {
732 vector_field: "vec".to_string(),
733 dim: 128,
734 payload_indexes: vec![("vec".to_string(), nodedb_types::PayloadIndexKind::Equality)],
735 ..VectorPrimaryConfig::default()
736 };
737 let cols = vec![("vec".to_string(), "VECTOR(128)".to_string())];
738 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
739 let msg = format!("{err}");
740 assert!(
741 msg.contains("bitmap-eligible"),
742 "expected bitmap-eligible in error: {msg}"
743 );
744 }
745
746 #[test]
747 fn validate_payload_indexes_blob_type_rejected() {
748 let mut cfg = VectorPrimaryConfig {
749 vector_field: "vec".to_string(),
750 dim: 128,
751 payload_indexes: vec![("data".to_string(), nodedb_types::PayloadIndexKind::Equality)],
752 ..VectorPrimaryConfig::default()
753 };
754 let cols = vec![
755 ("vec".to_string(), "VECTOR(128)".to_string()),
756 ("data".to_string(), "BLOB".to_string()),
757 ];
758 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
759 let msg = format!("{err}");
760 assert!(
761 msg.contains("bitmap-eligible"),
762 "expected bitmap-eligible in error: {msg}"
763 );
764 }
765}