From fe0f21e4203e0c4f2b995ccbe69e3663b5a2e86c Mon Sep 17 00:00:00 2001 From: Emmanuel Keller Date: Wed, 27 May 2026 16:54:16 +0100 Subject: [PATCH 1/4] Add count() scan exercising SurrealDB's COUNT index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `count_count_idx` scan that mirrors the existing `count` scan but attaches a `with_index` block with `index_type = "count"`. The framework runs both a non-indexed baseline leg and an indexed leg, so the two timings sit side-by-side in the output. Engine-side handling: - SurrealDB builds the new `DEFINE INDEX ... ON TABLE record COUNT CONCURRENTLY` index; the optimizer picks it up for the existing `SELECT count() FROM record GROUP ALL` query (no scan SQL change). - Neo4j short-circuits index build and switches the indexed-leg query to the labeled, predicate-free form (`MATCH (n:Record) RETURN count(n)`) so the label count store fires (O(1), exact). - ArangoDB does the same with `RETURN LENGTH(record)` (collection counter, O(1), exact). Adds a `build_index` override since ArangoDB previously had none. - Postgres / MySQL / MariaDB / SQLite / MongoDB short-circuit `build_index` to a no-op for the count case; the indexed leg runs the same query as the baseline (no exact fast-count is available natively on those engines). - KV / engines without a `build_index` override keep the default `NotSupported`; their indexed cells render as `-`. `Index.fields` becomes `#[serde(default)]` so configs can omit it for index types that take no field list. Verified end-to-end against embedded SurrealDB (3.2.0-alpha, RocksDB, 1M rows, 200 samples): the indexed leg runs ~2x faster than the same-scan baseline (2.0s mean vs 4.3s mean; 64 ops vs 31 ops). The absolute speedup floor is set by per-query WebSocket / parse overhead — at the storage layer the index is effectively constant-time. --- config/bench.toml | 13 +++++++++++++ config/ci.toml | 13 +++++++++++++ src/arangodb.rs | 41 +++++++++++++++++++++++++++++++++-------- src/main.rs | 5 ++++- src/mariadb.rs | 5 +++++ src/mongodb.rs | 6 ++++++ src/mysql.rs | 5 +++++ src/neo4j.rs | 31 +++++++++++++++++++++++++++---- src/postgres.rs | 5 +++++ src/sqlite.rs | 5 +++++ src/surrealdb.rs | 4 ++++ 11 files changed, 120 insertions(+), 13 deletions(-) diff --git a/config/bench.toml b/config/bench.toml index 3c29092c..8f9774a4 100644 --- a/config/bench.toml +++ b/config/bench.toml @@ -62,6 +62,19 @@ name = "count()" projection = "COUNT" samples = 1000 +# ============================================================================ +# count_count_idx +# ============================================================================ + +[[scans]] +id = "count_count_idx" +name = "count() with COUNT index" +projection = "COUNT" +samples = 1000 + +[scans.with_index] +index_type = "count" + # ============================================================================ # limit # ============================================================================ diff --git a/config/ci.toml b/config/ci.toml index c0963136..09c87a09 100644 --- a/config/ci.toml +++ b/config/ci.toml @@ -62,6 +62,19 @@ name = "count()" projection = "COUNT" samples = 10 +# ============================================================================ +# count_count_idx +# ============================================================================ + +[[scans]] +id = "count_count_idx" +name = "count() with COUNT index" +projection = "COUNT" +samples = 10 + +[scans.with_index] +index_type = "count" + # ============================================================================ # limit # ============================================================================ diff --git a/src/arangodb.rs b/src/arangodb.rs index e83b79e9..0e98ca6f 100644 --- a/src/arangodb.rs +++ b/src/arangodb.rs @@ -7,7 +7,7 @@ use crate::engine::{BenchmarkClient, BenchmarkEngine, ScanContext}; use crate::memory::Config; use crate::value::BenchValue; use crate::valueprovider::Columns; -use crate::{Benchmark, KeyType, Projection, Scan}; +use crate::{Benchmark, Index, KeyType, Projection, Scan}; use anyhow::{Result, bail}; use arangors::aql::AqlQuery; use arangors::client::ClientExt; @@ -273,20 +273,29 @@ impl BenchmarkClient for ArangoDBClient { } } - async fn scan_u32(&self, scan: &Scan, _ctx: ScanContext) -> Result { + async fn scan_u32(&self, scan: &Scan, ctx: ScanContext) -> Result { match self.keytype { KeyType::String506 => bail!(NOT_SUPPORTED_ERROR), - _ => self.scan(scan).await, + _ => self.scan(scan, ctx).await, } } - async fn scan_string(&self, scan: &Scan, _ctx: ScanContext) -> Result { + async fn scan_string(&self, scan: &Scan, ctx: ScanContext) -> Result { match self.keytype { KeyType::String506 => bail!(NOT_SUPPORTED_ERROR), - _ => self.scan(scan).await, + _ => self.scan(scan, ctx).await, } } + async fn build_index(&self, spec: &Index, _name: &str) -> Result<()> { + // COUNT-style scans are answered by ArangoDB's collection count with no + // DDL required — the speedup lives in the scan query branch below. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } + bail!(NOT_SUPPORTED_ERROR) + } + async fn batch_create_u32( &self, key_vals: impl Iterator + Send, @@ -475,7 +484,7 @@ impl ArangoDBClient { Ok(()) } - async fn scan(&self, scan: &Scan) -> Result { + async fn scan(&self, scan: &Scan, ctx: ScanContext) -> Result { // Extract parameters let l = match (scan.start, scan.limit) { (Some(s), Some(l)) => format!("LIMIT {s}, {l}"), @@ -486,6 +495,18 @@ impl ArangoDBClient { let c = ArangoDBDialect::filter_clause(scan)?; let o = ArangoDBDialect::sort_clause(scan)?; let p = scan.projection()?; + // Indexed leg of a COUNT-index scan: ArangoDB tracks an exact collection + // count, exposed via LENGTH(). Limited to predicate-free, unpaged scans. + let count_idx = ctx == ScanContext::WithIndex + && matches!(p, Projection::Count) + && scan + .with_index + .as_ref() + .and_then(|idx| idx.index_type.as_deref()) + == Some("count") + && c.is_empty() + && o.is_empty() + && l.is_empty(); // Perform the relevant projection scan type match p { Projection::Id => { @@ -517,8 +538,12 @@ impl ArangoDBClient { Ok(count) } Projection::Count => { - let stm = - format!("FOR r IN record {c} {l} COLLECT WITH COUNT INTO count RETURN count"); + let stm = if count_idx { + // Collection count: O(1), exact. + "RETURN LENGTH(record)".to_string() + } else { + format!("FOR r IN record {c} {l} COLLECT WITH COUNT INTO count RETURN count") + }; let res: Vec = self.database.aql_str(&stm).await.unwrap(); let count = res.first().unwrap().as_i64().unwrap(); Ok(count as usize) diff --git a/src/main.rs b/src/main.rs index d365bbf7..fad7faee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,7 +392,10 @@ pub(crate) struct Index { /// When true, skip index create/drop but still run the query leg (table scan). #[serde(default)] pub(crate) skip: bool, - /// Columns or paths included in the index. + /// Columns or paths included in the index. Optional for index types that + /// don't take field arguments (e.g. SurrealDB COUNT indexes apply to the + /// whole table). + #[serde(default)] pub(crate) fields: Vec, /// Whether the index enforces uniqueness when supported by the backend. pub(crate) unique: Option, diff --git a/src/mariadb.rs b/src/mariadb.rs index 18b4f171..1d3341ff 100644 --- a/src/mariadb.rs +++ b/src/mariadb.rs @@ -220,6 +220,11 @@ impl BenchmarkClient for MariadbClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MariaDB equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/mongodb.rs b/src/mongodb.rs index 3de57142..a09d3fd9 100644 --- a/src/mongodb.rs +++ b/src/mongodb.rs @@ -270,6 +270,12 @@ impl BenchmarkClient for MongoDBClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MongoDB equivalent (estimatedDocumentCount + // is approximate, not exact); the indexed scan leg runs the same query + // as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Define the index document let mut doc = Document::new(); // Check if an index type is specified diff --git a/src/mysql.rs b/src/mysql.rs index 6d806c3a..f3f5c998 100644 --- a/src/mysql.rs +++ b/src/mysql.rs @@ -216,6 +216,11 @@ impl BenchmarkClient for MysqlClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no MySQL equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/neo4j.rs b/src/neo4j.rs index 70a05e83..4b32cb32 100644 --- a/src/neo4j.rs +++ b/src/neo4j.rs @@ -151,6 +151,11 @@ impl BenchmarkClient for Neo4jClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style scans are answered by Neo4j's label count store with no + // DDL required — the speedup lives in the scan query branch below. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Reject wildcard array specs (`tags.*`). Cypher has no btree equivalent // for array-element indexing, and records are flattened so there is no // single property to index. Other dialects work around this with JSON @@ -555,6 +560,20 @@ impl Neo4jClient { .and_then(|idx| idx.index_type.as_ref()) .map(|t| t == "fulltext") .unwrap_or(false); + // Indexed leg of a COUNT-index scan: hit Neo4j's label count store with + // a labeled, predicate-free match. Limited to scans with no filter / + // ordering / paging — those would force a scan and bypass the store. + let count_idx = ctx == ScanContext::WithIndex + && matches!(p, Projection::Count) + && scan + .with_index + .as_ref() + .and_then(|idx| idx.index_type.as_deref()) + == Some("count") + && c.is_empty() + && o.is_empty() + && s.is_empty() + && l.is_empty(); // Perform the relevant projection scan type match p { Projection::Id => { @@ -588,11 +607,15 @@ impl Neo4jClient { Ok(count) } Projection::Count => { - let stm = match fts { - true => format!( + let stm = if count_idx { + // Label count store: O(1), exact. + "MATCH (n:Record) RETURN count(n) as count".to_string() + } else if fts { + format!( "CALL db.index.fulltext.queryNodes('{n}', '{c}') YIELD node as r WITH r {s} {l} RETURN count(r) as count" - ), - false => format!("MATCH (r) {c} WITH r {s} {l} RETURN count(r) as count"), + ) + } else { + format!("MATCH (r) {c} WITH r {s} {l} RETURN count(r) as count") }; let mut res = self.graph.execute(query(&stm)).await.unwrap(); let count: i64 = res.next().await.unwrap().unwrap().get("count").unwrap(); diff --git a/src/postgres.rs b/src/postgres.rs index 74f4fd6e..43cd656f 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -245,6 +245,11 @@ impl BenchmarkClient for PostgresClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no Postgres equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/sqlite.rs b/src/sqlite.rs index 8c73a471..8217aa78 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -220,6 +220,11 @@ impl BenchmarkClient for SqliteClient { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // COUNT-style indexes have no SQLite equivalent; the indexed scan + // leg runs the same query as the baseline so the row still populates. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } // Get the unique flag let unique = if spec.unique.unwrap_or(false) { "UNIQUE" diff --git a/src/surrealdb.rs b/src/surrealdb.rs index 3fea1dc7..b5e7007e 100644 --- a/src/surrealdb.rs +++ b/src/surrealdb.rs @@ -604,6 +604,10 @@ impl BenchmarkClient for SurrealDBClient { "DEFINE INDEX {name} ON TABLE record FIELDS {fields} FULLTEXT ANALYZER {name} BM25 CONCURRENTLY" ) } + Some(kind) if kind == "count" => { + // COUNT indexes apply to the whole table and accept no FIELDS / UNIQUE. + format!("DEFINE INDEX {name} ON TABLE record COUNT CONCURRENTLY") + } _ => { format!("DEFINE INDEX {name} ON TABLE record FIELDS {fields} {unique} CONCURRENTLY") } From 100e42892cdc1f6e2f49fb41d13a7b978b8d7b40 Mon Sep 17 00:00:00 2001 From: Emmanuel Keller Date: Wed, 27 May 2026 17:10:55 +0100 Subject: [PATCH 2/4] Make drop_index tolerant of missing indexes for COUNT no-op builds After build_index returns Ok(()) for index_type="count" the bench driver unconditionally schedules drop_index, but the named index was never created. MySQL, MariaDB, MongoDB, and ArangoDB had drop paths that would fail in that case and abort the count_count_idx run during cleanup. Make each drop tolerant of a missing index: - MySQL: SHOW INDEX existence check before issuing DROP (no native IF EXISTS). - MariaDB: same SHOW INDEX check for parity with MySQL. - MongoDB: list_index_names() check before drop_index. - ArangoDB: add a drop_index override returning Ok(()); the only build path that succeeds is the COUNT no-op, so there's no real index to drop. --- src/arangodb.rs | 6 ++++++ src/mariadb.rs | 13 ++++++++++++- src/mongodb.rs | 7 +++++++ src/mysql.rs | 12 +++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/arangodb.rs b/src/arangodb.rs index 0e98ca6f..b76a0e4b 100644 --- a/src/arangodb.rs +++ b/src/arangodb.rs @@ -296,6 +296,12 @@ impl BenchmarkClient for ArangoDBClient { bail!(NOT_SUPPORTED_ERROR) } + async fn drop_index(&self, _name: &str) -> Result<()> { + // Symmetric to `build_index`: the only path that returns `Ok` above is + // the COUNT no-op, so there's no real index to drop. + Ok(()) + } + async fn batch_create_u32( &self, key_vals: impl Iterator + Send, diff --git a/src/mariadb.rs b/src/mariadb.rs index 1d3341ff..6b4d51c0 100644 --- a/src/mariadb.rs +++ b/src/mariadb.rs @@ -260,8 +260,19 @@ impl BenchmarkClient for MariadbClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // MariaDB's `DROP INDEX` supports `IF EXISTS`, but we go through a + // `SHOW INDEX` lookup for parity with the MySQL adapter. Paired with + // the COUNT-index no-op `build_index` above, a missing index here is + // not an error. + let mut conn = self.conn.lock().await; + let exists: Option = conn + .query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")) + .await?; + if exists.is_none() { + return Ok(()); + } let stmt = format!("DROP INDEX {name} ON record"); - self.conn.lock().await.query_drop(&stmt).await?; + conn.query_drop(&stmt).await?; Ok(()) } diff --git a/src/mongodb.rs b/src/mongodb.rs index a09d3fd9..dc6618ea 100644 --- a/src/mongodb.rs +++ b/src/mongodb.rs @@ -314,6 +314,13 @@ impl BenchmarkClient for MongoDBClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // Paired with the COUNT-index no-op `build_index` above, the named + // index may not exist — check the catalog and skip the drop in that + // case so the cleanup leg of the new `count_count_idx` scan succeeds. + let names = self.collection().list_index_names().await?; + if !names.iter().any(|n| n == name) { + return Ok(()); + } self.collection().drop_index(name).await?; Ok(()) } diff --git a/src/mysql.rs b/src/mysql.rs index f3f5c998..d887e148 100644 --- a/src/mysql.rs +++ b/src/mysql.rs @@ -256,8 +256,18 @@ impl BenchmarkClient for MysqlClient { } async fn drop_index(&self, name: &str) -> Result<()> { + // MySQL's `DROP INDEX` has no `IF EXISTS`. Paired with the COUNT-index + // no-op `build_index` above, a missing index here is not an error — + // check existence first and skip the DDL when there's nothing to drop. + let mut conn = self.conn.lock().await; + let exists: Option = conn + .query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")) + .await?; + if exists.is_none() { + return Ok(()); + } let stmt = format!("DROP INDEX {name} ON record"); - self.conn.lock().await.query_drop(&stmt).await?; + conn.query_drop(&stmt).await?; Ok(()) } From 3b0da2ac9f62f0a24d9348db2c90f4dacda7566e Mon Sep 17 00:00:00 2001 From: Emmanuel Keller Date: Wed, 27 May 2026 17:32:48 +0100 Subject: [PATCH 3/4] Handle COUNT index path in the SurrealDB 2.x adapter; cargo fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/surrealdb2.rs: add the same no-op `build_index` short-circuit for `index_type = "count"` that the other engines have. Without it, the new `count_count_idx` scan reached the generic `DEFINE INDEX … FIELDS … ` branch with an empty fields list and aborted the run on `-d surrealdb2`. Drop is already safe — `drop_index` uses `REMOVE INDEX IF EXISTS`. - Apply `cargo fmt` to the per-engine count_idx predicates and SHOW INDEX lookups that fmt --check flagged. --- src/arangodb.rs | 6 +----- src/mariadb.rs | 5 ++--- src/mysql.rs | 5 ++--- src/neo4j.rs | 6 +----- src/surrealdb2.rs | 7 +++++++ 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/arangodb.rs b/src/arangodb.rs index b76a0e4b..812c1963 100644 --- a/src/arangodb.rs +++ b/src/arangodb.rs @@ -505,11 +505,7 @@ impl ArangoDBClient { // count, exposed via LENGTH(). Limited to predicate-free, unpaged scans. let count_idx = ctx == ScanContext::WithIndex && matches!(p, Projection::Count) - && scan - .with_index - .as_ref() - .and_then(|idx| idx.index_type.as_deref()) - == Some("count") + && scan.with_index.as_ref().and_then(|idx| idx.index_type.as_deref()) == Some("count") && c.is_empty() && o.is_empty() && l.is_empty(); diff --git a/src/mariadb.rs b/src/mariadb.rs index 6b4d51c0..73b7ded2 100644 --- a/src/mariadb.rs +++ b/src/mariadb.rs @@ -265,9 +265,8 @@ impl BenchmarkClient for MariadbClient { // the COUNT-index no-op `build_index` above, a missing index here is // not an error. let mut conn = self.conn.lock().await; - let exists: Option = conn - .query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")) - .await?; + let exists: Option = + conn.query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")).await?; if exists.is_none() { return Ok(()); } diff --git a/src/mysql.rs b/src/mysql.rs index d887e148..247dfa45 100644 --- a/src/mysql.rs +++ b/src/mysql.rs @@ -260,9 +260,8 @@ impl BenchmarkClient for MysqlClient { // no-op `build_index` above, a missing index here is not an error — // check existence first and skip the DDL when there's nothing to drop. let mut conn = self.conn.lock().await; - let exists: Option = conn - .query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")) - .await?; + let exists: Option = + conn.query_first(format!("SHOW INDEX FROM record WHERE Key_name = '{name}'")).await?; if exists.is_none() { return Ok(()); } diff --git a/src/neo4j.rs b/src/neo4j.rs index 4b32cb32..ab9fc538 100644 --- a/src/neo4j.rs +++ b/src/neo4j.rs @@ -565,11 +565,7 @@ impl Neo4jClient { // ordering / paging — those would force a scan and bypass the store. let count_idx = ctx == ScanContext::WithIndex && matches!(p, Projection::Count) - && scan - .with_index - .as_ref() - .and_then(|idx| idx.index_type.as_deref()) - == Some("count") + && scan.with_index.as_ref().and_then(|idx| idx.index_type.as_deref()) == Some("count") && c.is_empty() && o.is_empty() && s.is_empty() diff --git a/src/surrealdb2.rs b/src/surrealdb2.rs index aa6b00f1..54d1bb6e 100644 --- a/src/surrealdb2.rs +++ b/src/surrealdb2.rs @@ -467,6 +467,13 @@ impl BenchmarkClient for SurrealDB2Client { } async fn build_index(&self, spec: &Index, name: &str) -> Result<()> { + // SurrealDB 2.x has no COUNT-index feature; the indexed scan leg falls + // back to the same query as the baseline so the row still populates. + // Paired with `REMOVE INDEX IF EXISTS` in `drop_index` — the cleanup + // leg is already tolerant of a missing index. + if spec.index_type.as_deref() == Some("count") { + return Ok(()); + } let unique = if spec.unique.unwrap_or(false) { "UNIQUE" } else { From fc9b23028b5a791abeb643c789765707bdfb4d0a Mon Sep 17 00:00:00 2001 From: Emmanuel Keller Date: Wed, 27 May 2026 17:49:44 +0100 Subject: [PATCH 4/4] Reject fieldless `with_index` configs at parse time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that `Index.fields` defaults to an empty vector, a `[scans.with_index]` block with no `fields` and no fieldless `index_type` silently passed config loading and only failed at runtime with backend-specific DDL errors (e.g. `CREATE INDEX … ()`, `FIELDS …`). Extend `validate_scan_index_ids` to enforce: - `fields` must be non-empty unless `index_type` is one of the known fieldless variants (currently just `count`). - A fieldless `index_type` rejects a non-empty `fields` list to flag inconsistent configs. Adds the fieldless-types list as a module-level const and three unit tests covering both directions of the new check. --- src/main.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/main.rs b/src/main.rs index 5bbf4495..56fc2222 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,15 +338,40 @@ fn expand_scan_specs(specs: Vec) -> Result { Ok(scans) } +/// Index types whose DDL applies to the whole table and accepts no `FIELDS` clause. +/// Empty `Index.fields` is only valid when `index_type` is one of these. +const FIELDLESS_INDEX_TYPES: &[&str] = &["count"]; + +fn is_fieldless_index_type(kind: Option<&str>) -> bool { + matches!(kind, Some(k) if FIELDLESS_INDEX_TYPES.contains(&k)) +} + /// Every scan with a non-skipped `with_index` must supply a non-empty `id` for datastore index names. /// Vector-search scans use `vector_query.field` to drive both index creation and the KNN query; /// `with_index` is reserved for non-vector indexed scans and rejected on vector entries. +/// `with_index.fields` must be non-empty unless `index_type` is one of [`FIELDLESS_INDEX_TYPES`] — +/// otherwise the per-backend DDL builders emit broken `FIELDS ` clauses and fail at runtime. fn validate_scan_index_ids(scans: &[Scan]) -> Result<()> { for scan in scans { if let Some(ref idx) = scan.with_index && !idx.skip { scan.required_index_id()?; + let kind = idx.index_type.as_deref(); + if is_fieldless_index_type(kind) { + if !idx.fields.is_empty() { + bail!( + "scan `{}`: with_index.index_type = `{}` takes no `fields` — remove the `fields` entry", + scan.name, + kind.unwrap() + ); + } + } else if idx.fields.is_empty() { + bail!( + "scan `{}`: with_index.fields must be non-empty (only fieldless index types such as `count` may omit fields)", + scan.name + ); + } } if let Some(ref vq) = scan.vector_query { if vq.top_k == 0 { @@ -1071,6 +1096,36 @@ mod test { assert!(super::validate_scan_index_ids(&scans).is_ok()); } + #[test] + fn scan_with_index_rejects_empty_fields_without_fieldless_type() { + let specs: Vec = + serde_json::from_str(r#"[{"id":"x","name":"y","samples":1,"with_index":{}}]"#).unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + let err = super::validate_scan_index_ids(&scans).unwrap_err(); + assert!(err.to_string().contains("with_index.fields must be non-empty")); + } + + #[test] + fn scan_with_index_allows_empty_fields_for_count() { + let specs: Vec = serde_json::from_str( + r#"[{"id":"x","name":"y","samples":1,"with_index":{"index_type":"count"}}]"#, + ) + .unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + assert!(super::validate_scan_index_ids(&scans).is_ok()); + } + + #[test] + fn scan_with_index_rejects_fields_for_count() { + let specs: Vec = serde_json::from_str( + r#"[{"id":"x","name":"y","samples":1,"with_index":{"index_type":"count","fields":["n"]}}]"#, + ) + .unwrap(); + let scans = super::expand_scan_specs(specs).unwrap(); + let err = super::validate_scan_index_ids(&scans).unwrap_err(); + assert!(err.to_string().contains("takes no `fields`")); + } + #[test] fn scan_spec_with_writes_rejects_single_object() { let err = serde_json::from_str::>(