Skip to main content

nodedb_sql/ddl_ast/parse/
vector_primary.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Parse `WITH (primary='vector', ...)` options for CREATE COLLECTION DDL.
4//!
5//! This module is concerned only with extracting and validating the
6//! vector-primary options from the raw SQL string. Column-level validation
7//! (e.g. that `vector_field` names an actual `VECTOR(n)` column) happens
8//! at the DDL execution layer where the column list is available.
9
10use 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
18/// Known quantization codec names accepted in DDL.
19const VALID_QUANTIZATIONS: &[&str] = &[
20    "none", "sq8", "pq", "rabitq", "bbq", "binary", "ternary", "opq",
21];
22
23/// Parse vector-primary options from raw CREATE COLLECTION SQL.
24///
25/// The options are the pairs of the statement's top-level `WITH (...)`
26/// clause; a `WITH` or an option name inside the column list or a literal is
27/// never read. Values are lower-cased, and a `[...]` list value becomes its
28/// comma-joined items.
29///
30/// Returns `Ok(None)` if `primary` is absent or set to a non-vector value.
31/// Returns `Ok(Some(cfg))` if `primary = 'vector'` and all required options
32/// are present and valid.
33/// Returns `Err(NodeDbError)` for any validation failure, including a
34/// malformed WITH clause.
35pub 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
41/// Index of the first `WITH` token at parenthesis depth zero that opens a
42/// parenthesized list.
43fn 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
62/// The `(key, value)` pairs of the top-level `WITH (...)` clause, values
63/// lower-cased. Empty when the statement has no such clause.
64fn 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                    // A call-shaped value such as `time(1h)` keeps its argument list.
96                    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
108/// Validate that `vector_field` names a `VECTOR(n)` column in the provided
109/// column list. Call this after the column list is available.
110///
111/// `columns` is a slice of `(column_name, type_str)` pairs as stored in
112/// `StoredCollection::fields` (lowercased names, original-case type strings).
113pub 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
138/// Map a SQL column type (uppercased) to its payload bitmap kind.
139fn 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
155/// Validate that each `payload_indexes` field exists, is not a VECTOR/BLOB/BYTES
156/// type, and is bitmap-eligible (text/int/bool/timestamp). Also infers the
157/// per-field `PayloadIndexKind` from the column type — numeric / timestamp
158/// → `Range` (sorted BTreeMap), bool → `Boolean`, everything else →
159/// `Equality`. Mutates `cfg.payload_indexes` to attach the inferred kinds.
160pub 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
192/// Parse vector-primary options from pre-extracted `(key, value)` pairs.
193///
194/// This is the typed-AST entry point, used when the CREATE COLLECTION parser
195/// has already split the WITH clause into `Vec<(String, String)>`. The raw-SQL
196/// entry point (`parse_vector_primary_options`) delegates here after extracting
197/// its own pairs.
198pub 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    // A value that does not parse or does not fit is refused, never replaced
249    // by the default.
250    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    // payload_indexes is stored as a single value by the collection parser
274    // as a comma-separated list (stripped of bracket syntax).
275    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
307/// Parse a quantization string to `VectorQuantization`.
308fn 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
325/// Parse the optional `storage_dtype` DDL option. `None` (option omitted)
326/// resolves to the default `F32`; unknown values produce a typed
327/// `bad_request` naming the offending value.
328fn 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
339/// Parse a metric string to `DistanceMetric`.
340fn 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        // The old reader matched `dim` inside the column default and the
392        // literal, and so saw `dim=7`.
393        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        // The old reader replaced an out-of-range m or ef_construction with
425        // the default.
426        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    // ── Happy path ────────────────────────────────────────────────────────
438
439    #[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    // ── primary absent / non-vector returns None ──────────────────────────
498
499    #[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    // ── Missing required options ──────────────────────────────────────────
529
530    #[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    // ── Invalid quantization ──────────────────────────────────────────────
552
553    #[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    // ── All valid quantization strings ───────────────────────────────────
566
567    #[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    // ── validate_vector_field ─────────────────────────────────────────────
583
584    #[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    // ── validate_payload_indexes ──────────────────────────────────────────
631
632    #[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}