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 crate::parser::preprocess::lex::{
17    find_ascii_case_insensitive, find_ascii_case_insensitive_from, find_ascii_keyword,
18};
19
20/// Known quantization codec names accepted in DDL.
21const VALID_QUANTIZATIONS: &[&str] = &[
22    "none", "sq8", "pq", "rabitq", "bbq", "binary", "ternary", "opq",
23];
24
25/// Parse vector-primary options from raw CREATE COLLECTION SQL.
26///
27/// Returns `Ok(None)` if `primary` is absent or set to a non-vector value.
28/// Returns `Ok(Some(cfg))` if `primary = 'vector'` and all required options
29/// are present and valid.
30/// Returns `Err(NodeDbError)` for any validation failure.
31pub 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    // primary = 'vector' — require vector_field.
52    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    // Require dim.
61    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    // Optional: quantization (default: None / Sq8).
66    let quantization = match extract_with_str(sql, "quantization").as_deref() {
67        None => VectorQuantization::default(),
68        Some(q) => parse_quantization(q)?,
69    };
70
71    // Optional: m (default 16).
72    let m: u8 = extract_with_u32(sql, "m")
73        .and_then(|v| u8::try_from(v).ok())
74        .unwrap_or(16);
75
76    // Optional: ef_construction (default 200).
77    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    // Optional: metric (default Cosine).
82    let metric = match extract_with_str(sql, "metric").as_deref() {
83        None => DistanceMetric::Cosine,
84        Some(m) => parse_metric(m)?,
85    };
86
87    // Optional: storage_dtype (default F32).
88    let storage_dtype = parse_storage_dtype(extract_with_str(sql, "storage_dtype").as_deref())?;
89
90    // Optional: payload_indexes (array of quoted strings). Parser emits
91    // names only; the DDL handler infers the kind from each column's type
92    // before storing the final config.
93    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
110/// Validate that `vector_field` names a `VECTOR(n)` column in the provided
111/// column list. Call this after the column list is available.
112///
113/// `columns` is a slice of `(column_name, type_str)` pairs as stored in
114/// `StoredCollection::fields` (lowercased names, original-case type strings).
115pub 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
140/// Map a SQL column type (uppercased) to its payload bitmap kind.
141fn 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
157/// Validate that each `payload_indexes` field exists, is not a VECTOR/BLOB/BYTES
158/// type, and is bitmap-eligible (text/int/bool/timestamp). Also infers the
159/// per-field `PayloadIndexKind` from the column type — numeric / timestamp
160/// → `Range` (sorted BTreeMap), bool → `Boolean`, everything else →
161/// `Equality`. Mutates `cfg.payload_indexes` to attach the inferred kinds.
162pub 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
194/// Parse vector-primary options from pre-extracted `(key, value)` pairs.
195///
196/// This is the typed-AST entry point, used when the CREATE COLLECTION parser
197/// has already split the WITH clause into `Vec<(String, String)>`. The raw-SQL
198/// entry point (`parse_vector_primary_options`) delegates here after extracting
199/// its own pairs.
200pub 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    // payload_indexes is stored as a single value by the collection parser
265    // as a comma-separated list (stripped of bracket syntax).
266    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
298// ── Private helpers ───────────────────────────────────────────────────────────
299
300/// Find the substring inside the outermost `WITH (...)` clause, if any.
301/// Falls back to the whole SQL when no WITH clause is present.
302fn with_clause(sql: &str) -> &str {
303    let Some(pos) = find_ascii_keyword(sql, "WITH") else {
304        return sql;
305    };
306    // Whole-word check on WITH.
307    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
324/// Extract a `key = 'value'` or `key = "value"` string from SQL WITH options.
325fn extract_with_str(sql: &str, key: &str) -> Option<String> {
326    let scope = with_clause(sql);
327    // Find a whole-word, '='-followed occurrence; skip false matches like
328    // "m" inside "metric" or inside "dim".
329    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    // Value may be quoted with single or double quotes.
353    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    // Bare value (numeric-looking or unquoted identifier).
365    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
372/// Extract a `key = <integer>` value from SQL WITH options.
373fn extract_with_u32(sql: &str, key: &str) -> Option<u32> {
374    let raw = extract_with_str(sql, key)?;
375    raw.parse::<u32>().ok()
376}
377
378/// Extract `payload_indexes = ['a', 'b', ...]` from SQL.
379///
380/// Returns an empty `Vec` if the key is absent.
381fn 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    // Expect '[' ... ']'.
395    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    // Split by commas, strip quotes.
406    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
421/// Parse a quantization string to `VectorQuantization`.
422fn 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
439/// Parse the optional `storage_dtype` DDL option. `None` (option omitted)
440/// resolves to the default `F32`; unknown values produce a typed
441/// `bad_request` naming the offending value.
442fn 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
453/// Parse a metric string to `DistanceMetric`.
454fn 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    // ── Happy path ────────────────────────────────────────────────────────
500
501    #[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    // ── primary absent / non-vector returns None ──────────────────────────
560
561    #[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    // ── Missing required options ──────────────────────────────────────────
591
592    #[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    // ── Invalid quantization ──────────────────────────────────────────────
614
615    #[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    // ── All valid quantization strings ───────────────────────────────────
628
629    #[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    // ── validate_vector_field ─────────────────────────────────────────────
645
646    #[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    // ── validate_payload_indexes ──────────────────────────────────────────
693
694    #[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}