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 super::clause::{ClauseCursor, Tok, Token, tokenize};
17
18const VALID_QUANTIZATIONS: &[&str] = &[
20 "none", "sq8", "pq", "rabitq", "bbq", "binary", "ternary", "opq",
21];
22
23pub fn parse_vector_primary_options(sql: &str) -> Result<Option<VectorPrimaryConfig>, NodeDbError> {
36 let options = with_option_pairs(sql)
37 .map_err(|e| NodeDbError::bad_request(format!("WITH clause: {e}")))?;
38 parse_vector_primary_options_from_kvs(&options)
39}
40
41fn top_level_with(toks: &[Token<'_>]) -> Option<usize> {
44 let mut depth = 0i32;
45 for (i, tok) in toks.iter().enumerate() {
46 match &tok.tok {
47 Tok::Punct('(') => depth += 1,
48 Tok::Punct(')') => depth -= 1,
49 Tok::Word(w)
50 if depth == 0
51 && w.eq_ignore_ascii_case("WITH")
52 && toks.get(i + 1).is_some_and(|t| t.tok == Tok::Punct('(')) =>
53 {
54 return Some(i);
55 }
56 _ => {}
57 }
58 }
59 None
60}
61
62fn with_option_pairs(sql: &str) -> Result<Vec<(String, String)>, crate::error::SqlError> {
65 let toks = tokenize(sql);
66 let Some(at) = top_level_with(&toks) else {
67 return Ok(Vec::new());
68 };
69 let mut c = ClauseCursor::new(sql, &toks[at + 1..], "WITH");
70 c.paren_list("WITH options", |c| {
71 let key = c.word("option name")?.to_lowercase();
72 c.expect_punct('=')?;
73 let value = if c.eat_punct('[') {
74 let mut items = Vec::new();
75 if !c.eat_punct(']') {
76 loop {
77 let item = match c.string("list item") {
78 Ok(item) => item,
79 Err(_) => c.name("list item")?,
80 };
81 items.push(item);
82 if c.eat_punct(',') {
83 continue;
84 }
85 c.expect_punct(']')?;
86 break;
87 }
88 }
89 items.join(",")
90 } else {
91 match c.string(&key) {
92 Ok(value) => value,
93 Err(_) => {
94 let run = c.raw_run(&key)?.trim_matches('"').to_string();
95 if c.peek_punct('(') {
97 format!("{run}({})", c.raw_paren(&key)?)
98 } else {
99 run
100 }
101 }
102 }
103 };
104 Ok((key, value.trim().to_lowercase()))
105 })
106}
107
108pub fn validate_vector_field(
114 cfg: &VectorPrimaryConfig,
115 columns: &[(String, String)],
116) -> Result<(), NodeDbError> {
117 let col = columns
118 .iter()
119 .find(|(name, _)| name.eq_ignore_ascii_case(&cfg.vector_field));
120
121 let (_, type_str) = col.ok_or_else(|| {
122 NodeDbError::bad_request(format!(
123 "vector_field '{}' does not exist in the collection's column list",
124 cfg.vector_field
125 ))
126 })?;
127
128 if !type_str.to_uppercase().starts_with("VECTOR") {
129 return Err(NodeDbError::bad_request(format!(
130 "vector_field '{}' is of type '{}'; must be VECTOR(n)",
131 cfg.vector_field, type_str
132 )));
133 }
134
135 Ok(())
136}
137
138fn infer_payload_kind(upper_type: &str) -> nodedb_types::PayloadIndexKind {
140 use nodedb_types::PayloadIndexKind as K;
141 let head = upper_type
142 .split_once('(')
143 .map(|(p, _)| p)
144 .unwrap_or(upper_type)
145 .trim();
146 match head {
147 "BIGINT" | "INT" | "INTEGER" | "SMALLINT" | "TINYINT" | "BIGSERIAL" | "SERIAL"
148 | "FLOAT" | "DOUBLE" | "REAL" | "NUMERIC" | "DECIMAL" | "TIMESTAMP" | "TIMESTAMPTZ"
149 | "DATE" | "TIME" | "INSTANT" | "DATETIME" => K::Range,
150 "BOOL" | "BOOLEAN" => K::Boolean,
151 _ => K::Equality,
152 }
153}
154
155pub fn validate_payload_indexes(
161 cfg: &mut VectorPrimaryConfig,
162 columns: &[(String, String)],
163) -> Result<(), NodeDbError> {
164 for slot in cfg.payload_indexes.iter_mut() {
165 let field = slot.0.clone();
166 let col = columns
167 .iter()
168 .find(|(name, _)| name.eq_ignore_ascii_case(&field));
169
170 let (_, type_str) = col.ok_or_else(|| {
171 NodeDbError::bad_request(format!(
172 "payload_indexes field '{field}' does not exist in the collection's column list"
173 ))
174 })?;
175
176 let upper_type = type_str.to_uppercase();
177 if upper_type.starts_with("VECTOR")
178 || upper_type == "BLOB"
179 || upper_type == "BYTES"
180 || upper_type == "BYTEA"
181 {
182 return Err(NodeDbError::bad_request(format!(
183 "payload_indexes field '{field}' has type '{type_str}' which is not bitmap-eligible; \
184 only text, integer, boolean, and timestamp types are supported"
185 )));
186 }
187 slot.1 = infer_payload_kind(&upper_type);
188 }
189 Ok(())
190}
191
192pub fn parse_vector_primary_options_from_kvs(
199 options: &[(String, String)],
200) -> Result<Option<VectorPrimaryConfig>, NodeDbError> {
201 let get = |key: &str| -> Option<String> {
202 options
203 .iter()
204 .find(|(k, _)| k.eq_ignore_ascii_case(key))
205 .map(|(_, v)| v.clone())
206 };
207
208 let primary_val = get("primary");
209 match primary_val.as_deref() {
210 None
211 | Some("document_schemaless")
212 | Some("document_strict")
213 | Some("kv")
214 | Some("columnar")
215 | Some("timeseries")
216 | Some("spatial") => return Ok(None),
217 Some("vector") => {}
218 Some(other) => {
219 return Err(NodeDbError::bad_request(format!(
220 "unknown primary engine '{other}'; valid values: \
221 document_schemaless, document_strict, kv, columnar, timeseries, spatial, vector"
222 )));
223 }
224 }
225
226 let vector_field = get("vector_field")
227 .ok_or_else(|| NodeDbError::bad_request("primary='vector' requires vector_field option"))?;
228 if vector_field.is_empty() {
229 return Err(NodeDbError::bad_request(
230 "vector_field must be a non-empty column name",
231 ));
232 }
233
234 let dim_raw = get("dim").ok_or_else(|| {
235 NodeDbError::bad_request("primary='vector' requires dim option (e.g. dim=1024)")
236 })?;
237 let dim = dim_raw.trim().parse::<u32>().map_err(|_| {
238 NodeDbError::bad_request(format!(
239 "dim must be a non-negative integer (e.g. dim=1024), got '{dim_raw}'"
240 ))
241 })?;
242
243 let quantization = match get("quantization").as_deref() {
244 None => VectorQuantization::default(),
245 Some(q) => parse_quantization(q)?,
246 };
247
248 let m: u8 = match get("m") {
251 None => 16,
252 Some(raw) => raw.trim().parse::<u8>().map_err(|_| {
253 NodeDbError::bad_request(format!("m must be an integer in 0..=255, got '{raw}'"))
254 })?,
255 };
256
257 let ef_construction: u16 = match get("ef_construction") {
258 None => 200,
259 Some(raw) => raw.trim().parse::<u16>().map_err(|_| {
260 NodeDbError::bad_request(format!(
261 "ef_construction must be an integer in 0..=65535, got '{raw}'"
262 ))
263 })?,
264 };
265
266 let metric = match get("metric").as_deref() {
267 None => DistanceMetric::Cosine,
268 Some(m) => parse_metric(m)?,
269 };
270
271 let storage_dtype = parse_storage_dtype(get("storage_dtype").as_deref())?;
272
273 let payload_indexes = get("payload_indexes")
276 .map(|v| {
277 v.split(',')
278 .filter_map(|s| {
279 let s = s
280 .trim()
281 .trim_matches('\'')
282 .trim_matches('"')
283 .trim()
284 .to_lowercase();
285 if s.is_empty() {
286 None
287 } else {
288 Some((s, nodedb_types::PayloadIndexKind::Equality))
289 }
290 })
291 .collect::<Vec<_>>()
292 })
293 .unwrap_or_default();
294
295 Ok(Some(VectorPrimaryConfig {
296 vector_field,
297 dim,
298 quantization,
299 m,
300 ef_construction,
301 metric,
302 storage_dtype,
303 payload_indexes,
304 }))
305}
306
307fn parse_quantization(q: &str) -> Result<VectorQuantization, NodeDbError> {
309 match q.to_lowercase().as_str() {
310 "none" => Ok(VectorQuantization::None),
311 "sq8" => Ok(VectorQuantization::Sq8),
312 "pq" => Ok(VectorQuantization::Pq),
313 "rabitq" => Ok(VectorQuantization::RaBitQ),
314 "bbq" => Ok(VectorQuantization::Bbq),
315 "binary" => Ok(VectorQuantization::Binary),
316 "ternary" => Ok(VectorQuantization::Ternary),
317 "opq" => Ok(VectorQuantization::Opq),
318 other => Err(NodeDbError::bad_request(format!(
319 "unknown quantization '{other}'; valid values: {}",
320 VALID_QUANTIZATIONS.join(", ")
321 ))),
322 }
323}
324
325fn parse_storage_dtype(s: Option<&str>) -> Result<VectorStorageDtype, NodeDbError> {
329 let Some(s) = s else {
330 return Ok(VectorStorageDtype::default());
331 };
332 VectorStorageDtype::parse(s).ok_or_else(|| {
333 NodeDbError::bad_request(format!(
334 "unknown storage_dtype '{s}'; valid values: f32, f16, bf16"
335 ))
336 })
337}
338
339fn parse_metric(m: &str) -> Result<DistanceMetric, NodeDbError> {
341 match m.to_lowercase().as_str() {
342 "l2" | "euclidean" => Ok(DistanceMetric::L2),
343 "cosine" => Ok(DistanceMetric::Cosine),
344 "ip" | "inner_product" | "innerproduct" | "dot" => Ok(DistanceMetric::InnerProduct),
345 "manhattan" | "l1" => Ok(DistanceMetric::Manhattan),
346 "chebyshev" | "linf" | "l_inf" => Ok(DistanceMetric::Chebyshev),
347 "hamming" => Ok(DistanceMetric::Hamming),
348 "jaccard" => Ok(DistanceMetric::Jaccard),
349 "pearson" => Ok(DistanceMetric::Pearson),
350 other => Err(NodeDbError::bad_request(format!(
351 "unknown distance metric '{other}'; valid values: l2, cosine, ip, manhattan, \
352 chebyshev, hamming, jaccard, pearson"
353 ))),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn pairs(sql: &str) -> Vec<(String, String)> {
362 with_option_pairs(sql).expect("WITH parses")
363 }
364
365 #[test]
366 fn with_clause_after_unicode_schema_preserves_original_offsets() {
367 assert_eq!(
368 pairs("CREATE COLLECTION v (name ffff) WITH (primary='vector')"),
369 vec![("primary".to_string(), "vector".to_string())]
370 );
371 }
372
373 #[test]
374 fn option_after_unicode_value_preserves_original_offsets() {
375 assert_eq!(
376 pairs("CREATE COLLECTION v WITH (note='ffff', metric='cosine')")[1],
377 ("metric".to_string(), "cosine".to_string())
378 );
379 }
380
381 #[test]
382 fn payload_indexes_list_is_comma_joined() {
383 assert_eq!(
384 pairs("CREATE COLLECTION v WITH (note='ffff', payload_indexes=['category', \"Tag\"])")[1],
385 ("payload_indexes".to_string(), "category,tag".to_string())
386 );
387 }
388
389 #[test]
390 fn option_name_inside_a_literal_or_column_list_is_not_read() {
391 let sql = "CREATE COLLECTION v (note TEXT DEFAULT 'dim=7', vec VECTOR(64)) \
394 WITH (primary='vector', vector_field='vec', dim=64)";
395 let cfg = parse_vector_primary_options(sql)
396 .expect("parse ok")
397 .expect("should be Some");
398 assert_eq!(cfg.dim, 64);
399 }
400
401 #[test]
402 fn with_inside_column_list_is_not_the_options_clause() {
403 let toks = tokenize("CREATE COLLECTION v (a TEXT CHECK (a WITH (x))) WITH (dim=3)");
404 let at = top_level_with(&toks).expect("top-level WITH");
405 assert_eq!(toks[at + 2].tok, Tok::Word("dim"));
406 }
407
408 #[test]
409 fn call_shaped_value_keeps_its_arguments() {
410 assert_eq!(
411 pairs("CREATE COLLECTION v WITH (partition_by=time(1h), dim=3)")[0],
412 ("partition_by".to_string(), "time(1h)".to_string())
413 );
414 }
415
416 #[test]
417 fn malformed_with_clause_is_an_error() {
418 assert!(parse_vector_primary_options("CREATE COLLECTION v WITH (dim 3)").is_err());
419 assert!(parse_vector_primary_options("CREATE COLLECTION v WITH (dim=3").is_err());
420 }
421
422 #[test]
423 fn out_of_range_hnsw_options_are_refused() {
424 let base = "CREATE COLLECTION c (v VECTOR(64)) WITH (primary='vector', \
427 vector_field='v', dim=64";
428 for extra in [", m=300)", ", ef_construction=70000)", ", m=abc)"] {
429 let sql = format!("{base}{extra}");
430 assert!(parse_vector_primary_options(&sql).is_err(), "{sql}");
431 }
432 let bad_dim = "CREATE COLLECTION c (v VECTOR(64)) WITH (primary='vector', \
433 vector_field='v', dim=-1)";
434 assert!(parse_vector_primary_options(bad_dim).is_err());
435 }
436
437 #[test]
440 fn happy_path_full_options() {
441 let sql = "CREATE COLLECTION embeds \
442 (id BIGINT PRIMARY KEY, vec VECTOR(1024), category TEXT) \
443 WITH (primary='vector', vector_field='vec', dim=1024, \
444 quantization='rabitq', m=32, ef_construction=200, \
445 metric='cosine', payload_indexes=['category'])";
446 let cfg = parse_vector_primary_options(sql)
447 .expect("parse ok")
448 .expect("should be Some");
449 assert_eq!(cfg.vector_field, "vec");
450 assert_eq!(cfg.dim, 1024);
451 assert_eq!(cfg.quantization, VectorQuantization::RaBitQ);
452 assert_eq!(cfg.m, 32);
453 assert_eq!(cfg.ef_construction, 200);
454 assert_eq!(cfg.metric, DistanceMetric::Cosine);
455 assert_eq!(
456 cfg.payload_indexes,
457 vec![(
458 "category".to_string(),
459 nodedb_types::PayloadIndexKind::Equality
460 )]
461 );
462 }
463
464 #[test]
465 fn happy_path_minimal_options() {
466 let sql = "CREATE COLLECTION v (id BIGINT PRIMARY KEY, vec VECTOR(128)) \
467 WITH (primary='vector', vector_field='vec', dim=128)";
468 let cfg = parse_vector_primary_options(sql)
469 .expect("parse ok")
470 .expect("should be Some");
471 assert_eq!(cfg.vector_field, "vec");
472 assert_eq!(cfg.dim, 128);
473 assert_eq!(cfg.m, 16);
474 assert_eq!(cfg.ef_construction, 200);
475 assert_eq!(cfg.metric, DistanceMetric::Cosine);
476 assert!(cfg.payload_indexes.is_empty());
477 }
478
479 #[test]
480 fn happy_path_multiple_payload_indexes() {
481 let sql = "CREATE COLLECTION v (id BIGINT PRIMARY KEY, vec VECTOR(128), a TEXT, b INT) \
482 WITH (primary='vector', vector_field='vec', dim=128, \
483 payload_indexes=['a', 'b'])";
484 let cfg = parse_vector_primary_options(sql)
485 .expect("parse ok")
486 .expect("should be Some");
487 use nodedb_types::PayloadIndexKind as K;
488 assert_eq!(
489 cfg.payload_indexes,
490 vec![
491 ("a".to_string(), K::Equality),
492 ("b".to_string(), K::Equality)
493 ]
494 );
495 }
496
497 #[test]
500 fn no_primary_returns_none() {
501 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY)";
502 let result = parse_vector_primary_options(sql).expect("parse ok");
503 assert!(result.is_none());
504 }
505
506 #[test]
507 fn primary_document_returns_none() {
508 let sql =
509 "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='document_schemaless')";
510 let result = parse_vector_primary_options(sql).expect("parse ok");
511 assert!(result.is_none());
512 }
513
514 #[test]
515 fn primary_strict_returns_none() {
516 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='document_strict')";
517 let result = parse_vector_primary_options(sql).expect("parse ok");
518 assert!(result.is_none());
519 }
520
521 #[test]
522 fn primary_columnar_returns_none() {
523 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY) WITH (primary='columnar')";
524 let result = parse_vector_primary_options(sql).expect("parse ok");
525 assert!(result.is_none());
526 }
527
528 #[test]
531 fn missing_vector_field_returns_error() {
532 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
533 WITH (primary='vector', dim=64)";
534 let err = parse_vector_primary_options(sql).expect_err("should error");
535 let msg = format!("{err}");
536 assert!(
537 msg.contains("vector_field"),
538 "expected vector_field in error: {msg}"
539 );
540 }
541
542 #[test]
543 fn missing_dim_returns_error() {
544 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
545 WITH (primary='vector', vector_field='v')";
546 let err = parse_vector_primary_options(sql).expect_err("should error");
547 let msg = format!("{err}");
548 assert!(msg.contains("dim"), "expected dim in error: {msg}");
549 }
550
551 #[test]
554 fn unknown_quantization_returns_error() {
555 let sql = "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
556 WITH (primary='vector', vector_field='v', dim=64, quantization='ivfflat')";
557 let err = parse_vector_primary_options(sql).expect_err("should error");
558 let msg = format!("{err}");
559 assert!(
560 msg.contains("ivfflat"),
561 "expected codec name in error: {msg}"
562 );
563 }
564
565 #[test]
568 fn all_valid_quantizations_accepted() {
569 for q in VALID_QUANTIZATIONS {
570 let sql = format!(
571 "CREATE COLLECTION c (id BIGINT PRIMARY KEY, v VECTOR(64)) \
572 WITH (primary='vector', vector_field='v', dim=64, quantization='{q}')"
573 );
574 let result = parse_vector_primary_options(&sql);
575 assert!(
576 result.is_ok(),
577 "quantization '{q}' should be accepted, got: {result:?}"
578 );
579 }
580 }
581
582 #[test]
585 fn validate_vector_field_ok() {
586 let cfg = VectorPrimaryConfig {
587 vector_field: "vec".to_string(),
588 dim: 128,
589 ..VectorPrimaryConfig::default()
590 };
591 let cols = vec![
592 ("id".to_string(), "BIGINT".to_string()),
593 ("vec".to_string(), "VECTOR(128)".to_string()),
594 ];
595 validate_vector_field(&cfg, &cols).expect("should be ok");
596 }
597
598 #[test]
599 fn validate_vector_field_nonexistent_column_errors() {
600 let cfg = VectorPrimaryConfig {
601 vector_field: "missing".to_string(),
602 dim: 128,
603 ..VectorPrimaryConfig::default()
604 };
605 let cols = vec![("id".to_string(), "BIGINT".to_string())];
606 let err = validate_vector_field(&cfg, &cols).expect_err("should error");
607 let msg = format!("{err}");
608 assert!(
609 msg.contains("missing"),
610 "expected column name in error: {msg}"
611 );
612 }
613
614 #[test]
615 fn validate_vector_field_wrong_type_errors() {
616 let cfg = VectorPrimaryConfig {
617 vector_field: "name".to_string(),
618 dim: 128,
619 ..VectorPrimaryConfig::default()
620 };
621 let cols = vec![("name".to_string(), "TEXT".to_string())];
622 let err = validate_vector_field(&cfg, &cols).expect_err("should error");
623 let msg = format!("{err}");
624 assert!(
625 msg.contains("VECTOR"),
626 "expected VECTOR mention in error: {msg}"
627 );
628 }
629
630 #[test]
633 fn validate_payload_indexes_ok() {
634 let mut cfg = VectorPrimaryConfig {
635 vector_field: "vec".to_string(),
636 dim: 128,
637 payload_indexes: vec![(
638 "category".to_string(),
639 nodedb_types::PayloadIndexKind::Equality,
640 )],
641 ..VectorPrimaryConfig::default()
642 };
643 let cols = vec![
644 ("vec".to_string(), "VECTOR(128)".to_string()),
645 ("category".to_string(), "TEXT".to_string()),
646 ];
647 validate_payload_indexes(&mut cfg, &cols).expect("should be ok");
648 }
649
650 #[test]
651 fn validate_payload_indexes_nonexistent_errors() {
652 let mut cfg = VectorPrimaryConfig {
653 vector_field: "vec".to_string(),
654 dim: 128,
655 payload_indexes: vec![(
656 "ghost".to_string(),
657 nodedb_types::PayloadIndexKind::Equality,
658 )],
659 ..VectorPrimaryConfig::default()
660 };
661 let cols = vec![("vec".to_string(), "VECTOR(128)".to_string())];
662 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
663 let msg = format!("{err}");
664 assert!(msg.contains("ghost"), "expected field name in error: {msg}");
665 }
666
667 #[test]
668 fn validate_payload_indexes_vector_type_rejected() {
669 let mut cfg = VectorPrimaryConfig {
670 vector_field: "vec".to_string(),
671 dim: 128,
672 payload_indexes: vec![("vec".to_string(), nodedb_types::PayloadIndexKind::Equality)],
673 ..VectorPrimaryConfig::default()
674 };
675 let cols = vec![("vec".to_string(), "VECTOR(128)".to_string())];
676 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
677 let msg = format!("{err}");
678 assert!(
679 msg.contains("bitmap-eligible"),
680 "expected bitmap-eligible in error: {msg}"
681 );
682 }
683
684 #[test]
685 fn validate_payload_indexes_blob_type_rejected() {
686 let mut cfg = VectorPrimaryConfig {
687 vector_field: "vec".to_string(),
688 dim: 128,
689 payload_indexes: vec![("data".to_string(), nodedb_types::PayloadIndexKind::Equality)],
690 ..VectorPrimaryConfig::default()
691 };
692 let cols = vec![
693 ("vec".to_string(), "VECTOR(128)".to_string()),
694 ("data".to_string(), "BLOB".to_string()),
695 ];
696 let err = validate_payload_indexes(&mut cfg, &cols).expect_err("should error");
697 let msg = format!("{err}");
698 assert!(
699 msg.contains("bitmap-eligible"),
700 "expected bitmap-eligible in error: {msg}"
701 );
702 }
703}