#![allow(clippy::unwrap_used, clippy::panic, clippy::print_stderr)]
use std::sync::Arc;
use axum::{Router, body::Body, routing::post};
use fraiseql_core::{
db::postgres::PostgresAdapter,
prelude::DatabaseAdapter as _,
runtime::Executor,
schema::{
CompiledSchema, FieldDefinition, FieldType, QueryDefinition, TypeDefinition, VectorConfig,
},
};
use fraiseql_server::routes::graphql::{AppState, graphql_handler};
use fraiseql_test_support::try_database_url;
use http::{Request, StatusCode};
use serde_json::{Value, json};
use tower::ServiceExt as _;
const SCHEMA: &str = "p27_vec";
async fn seed(adapter: &PostgresAdapter) {
let stmts = vec![
"CREATE EXTENSION IF NOT EXISTS vector".to_string(),
format!("DROP SCHEMA IF EXISTS {SCHEMA} CASCADE"),
format!("CREATE SCHEMA {SCHEMA}"),
format!(
"CREATE TABLE {SCHEMA}.tb_doc (id bigint PRIMARY KEY, title text NOT NULL, \
embedding vector(3) NOT NULL, thumbnail vector(2) NOT NULL, \
fingerprint bit(8) NOT NULL, compact halfvec(3) NOT NULL, \
terms sparsevec(5) NOT NULL)"
),
format!(
"INSERT INTO {SCHEMA}.tb_doc VALUES \
(1, 'exact', '[1,0,0]', '[4,0]', '11111111', '[1,0,0]', \
'{{1:1}}/5'), \
(2, 'longer', '[5,0.01,0]', '[3,0]', '11110000', '[5,0.01,0]', \
'{{1:5,2:0.01}}/5'), \
(3, 'shorter', '[0.1,0.02,0]', '[2,0]', '00000011', '[0.1,0.02,0]', \
'{{1:0.1,2:0.02}}/5'), \
(4, 'orthogonal', '[0,1,0]', '[1,0]', '10000000', '[0,1,0]', \
'{{2:1}}/5')"
),
format!(
"CREATE VIEW {SCHEMA}.v_doc AS SELECT id, jsonb_build_object('id', id, 'title', \
title, 'embedding', embedding::text, 'fingerprint', fingerprint::text, \
'compact', compact::text, 'terms', terms::text) AS data, \
embedding, thumbnail, fingerprint, compact, terms FROM {SCHEMA}.tb_doc"
),
format!(
"CREATE TABLE {SCHEMA}.tb_skew (id bigint PRIMARY KEY, title text NOT NULL, \
embedding vector(3) NOT NULL, payload vector(3) NOT NULL, \
fingerprint bit(8) NOT NULL, fingerprint_payload bit(8) NOT NULL)"
),
format!(
"INSERT INTO {SCHEMA}.tb_skew VALUES \
(10, 'column-near', '[1,0,0]', '[0,1,0]', '11110000', '00001111'), \
(11, 'payload-near', '[0,1,0]', '[1,0,0]', '00001111', '11110000')"
),
format!(
"CREATE VIEW {SCHEMA}.v_skew AS SELECT id, jsonb_build_object('id', id, 'title', \
title, 'embedding', payload::text, 'fingerprint', fingerprint_payload::text) \
AS data, embedding, fingerprint FROM {SCHEMA}.tb_skew"
),
];
for stmt in stmts {
let _: Vec<std::collections::HashMap<String, Value>> =
adapter.execute_raw_query(&stmt).await.expect("fixture setup (needs pgvector)");
}
}
fn schema() -> CompiledSchema {
let mut schema = CompiledSchema::new();
let mut doc = TypeDefinition::new("Doc", format!("{SCHEMA}.v_doc"));
doc.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::new("title", FieldType::String),
FieldDefinition::new("embedding", FieldType::Vector)
.with_vector_config(VectorConfig::new(3)),
];
schema.types.push(doc);
let mut multi = TypeDefinition::new("MultiDoc", format!("{SCHEMA}.v_doc"));
multi.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::new("title", FieldType::String),
FieldDefinition::new("embedding", FieldType::Vector)
.with_vector_config(VectorConfig::new(3)),
FieldDefinition::new("thumbnail", FieldType::Vector).with_vector_config(VectorConfig {
dimensions: 2,
distance_metric: fraiseql_core::schema::DistanceMetric::L2,
..VectorConfig::new(2)
}),
FieldDefinition::new("thumbnailDistance", FieldType::Float)
.with_vector_distance("embedding"),
];
schema.types.push(multi);
let mut multi_docs = QueryDefinition::new("multiDocs", "MultiDoc")
.returning_list()
.with_sql_source(format!("{SCHEMA}.v_doc"));
multi_docs.auto_params.has_where = true;
multi_docs.auto_params.has_limit = true;
multi_docs.auto_params.has_offset = true;
schema.queries.push(multi_docs);
doc_fields_with_similarity(&mut schema);
let mut docs = QueryDefinition::new("docs", "Doc")
.returning_list()
.with_sql_source(format!("{SCHEMA}.v_doc"));
docs.auto_params.has_where = true;
docs.auto_params.has_limit = true;
docs.auto_params.has_offset = true;
schema.queries.push(docs);
for (type_name, query_name, metric) in [
("BitDoc", "bitDocs", fraiseql_core::schema::DistanceMetric::Hamming),
("JacDoc", "jacDocs", fraiseql_core::schema::DistanceMetric::Jaccard),
] {
let mut bit_doc = TypeDefinition::new(type_name, format!("{SCHEMA}.v_doc"));
bit_doc.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::new("title", FieldType::String),
FieldDefinition::bit_vector(
"fingerprint",
VectorConfig {
distance_metric: metric,
..VectorConfig::binary(8)
},
),
FieldDefinition::new("bitDistance", FieldType::Float)
.with_vector_distance("fingerprint"),
];
schema.types.push(bit_doc);
let mut query = QueryDefinition::new(query_name, type_name)
.returning_list()
.with_sql_source(format!("{SCHEMA}.v_doc"));
query.auto_params.has_where = true;
query.auto_params.has_limit = true;
query.auto_params.has_offset = true;
schema.queries.push(query);
}
let mut compact = TypeDefinition::new("CompactDoc", format!("{SCHEMA}.v_doc"));
compact.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::new("compact", FieldType::HalfVector)
.with_vector_config(VectorConfig::new(3)),
FieldDefinition::new("terms", FieldType::SparseVector).with_vector_config(VectorConfig {
dimensions: 5,
distance_metric: fraiseql_core::schema::DistanceMetric::L2,
..VectorConfig::new(5)
}),
FieldDefinition::new("compactDistance", FieldType::Float).with_vector_distance("compact"),
];
schema.types.push(compact);
let mut compact_docs = QueryDefinition::new("compactDocs", "CompactDoc")
.returning_list()
.with_sql_source(format!("{SCHEMA}.v_doc"));
compact_docs.auto_params.has_where = true;
compact_docs.auto_params.has_limit = true;
schema.queries.push(compact_docs);
let mut skew = TypeDefinition::new("SkewDoc", format!("{SCHEMA}.v_skew"));
skew.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::new("title", FieldType::String),
FieldDefinition::new("embedding", FieldType::Vector)
.with_vector_config(VectorConfig::new(3)),
];
schema.types.push(skew);
let mut skew_bit = TypeDefinition::new("SkewBitDoc", format!("{SCHEMA}.v_skew"));
skew_bit.fields = vec![
FieldDefinition::new("id", FieldType::Int),
FieldDefinition::bit_vector("fingerprint", VectorConfig::binary(8)),
];
schema.types.push(skew_bit);
for (type_name, query_name) in [("SkewDoc", "skewDocs"), ("SkewBitDoc", "skewBitDocs")] {
let mut query = QueryDefinition::new(query_name, type_name)
.returning_list()
.with_sql_source(format!("{SCHEMA}.v_skew"));
query.auto_params.has_where = true;
query.auto_params.has_limit = true;
schema.queries.push(query);
}
schema.build_indexes();
schema
}
fn doc_fields_with_similarity(schema: &mut CompiledSchema) {
let doc = schema.types.iter_mut().find(|t| t.name == "Doc").expect("Doc type");
doc.fields.push(
FieldDefinition::new("similarity", FieldType::Float).with_vector_distance("embedding"),
);
doc.fields
.push(FieldDefinition::new("gated", FieldType::String).with_authorize(true));
}
async fn setup() -> Option<Router> {
let url = try_database_url()?;
let adapter = PostgresAdapter::new(&url).await.expect("connect to the test database");
seed(&adapter).await;
let state = AppState::new(Arc::new(Executor::new(schema(), Arc::new(adapter))));
Some(Router::new().route("/graphql", post(graphql_handler)).with_state(state))
}
async fn post_graphql(router: Router, body: &Value) -> (StatusCode, Value) {
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/graphql")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap())
}
fn ids(response: &Value) -> Vec<i64> {
ids_under(response, "docs")
}
fn ids_under(response: &Value, key: &str) -> Vec<i64> {
response["data"][key]
.as_array()
.unwrap_or_else(|| panic!("expected data.{key} list, got: {response}"))
.iter()
.map(|d| d["id"].as_i64().expect("id"))
.collect()
}
fn skip(test: &str) -> bool {
if try_database_url().is_none() {
eprintln!("SKIP {test}: DATABASE_URL not set");
return true;
}
false
}
#[tokio::test]
async fn nearest_orders_by_the_declared_cosine_metric() {
if skip("nearest_cosine") {
return;
}
let router = setup().await.unwrap();
let query = r"{ docs(nearest: {vector: [1, 0, 0], k: 4}) { id title } }";
let (status, resp) = post_graphql(router, &json!({"query": query})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids(&resp),
vec![1, 2, 3, 4],
"cosine similarity order, proven by executed rows — not sql.contains: {resp}"
);
}
#[tokio::test]
async fn nearest_metric_override_changes_the_row_order() {
if skip("nearest_l2_and_ip") {
return;
}
let router = setup().await.unwrap();
let l2 = r#"{ docs(nearest: {vector: [1, 0, 0], k: 4, metric: "l2"}) { id } }"#;
let (status, resp) = post_graphql(router.clone(), &json!({"query": l2})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(ids(&resp), vec![1, 3, 4, 2], "L2 puts the long vector last: {resp}");
let ip = r#"{ docs(nearest: {vector: [1, 0, 0], k: 4, metric: "inner_product"}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": ip})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(ids(&resp), vec![2, 1, 3, 4], "inner product rewards magnitude: {resp}");
}
#[tokio::test]
async fn nearest_k_limits_the_rows() {
if skip("nearest_k") {
return;
}
let router = setup().await.unwrap();
let query = r"{ docs(nearest: {vector: [1, 0, 0], k: 2}) { id } }";
let (status, resp) = post_graphql(router, &json!({"query": query})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(ids(&resp), vec![1, 2]);
}
#[tokio::test]
async fn nearest_composes_with_a_where_filter() {
if skip("nearest_where_compose") {
return;
}
let router = setup().await.unwrap();
let query =
r#"{ docs(where: {title: {neq: "exact"}}, nearest: {vector: [1, 0, 0], k: 4}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": query})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(ids(&resp), vec![2, 3, 4], "filtered AND similarity-ordered: {resp}");
}
#[tokio::test]
async fn nearest_dimension_mismatch_is_refused() {
if skip("nearest_dims") {
return;
}
let router = setup().await.unwrap();
let query = r"{ docs(nearest: {vector: [1, 0], k: 2}) { id } }";
let (_status, resp) = post_graphql(router, &json!({"query": query})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("2 components") && msg.contains("3 dimensions"),
"the refusal names both sides of the mismatch: {resp}"
);
}
#[tokio::test]
async fn cosine_distance_threshold_filter_returns_the_rows_within_it() {
if skip("threshold_filter") {
return;
}
let router = setup().await.unwrap();
let query = r"{ docs(where: {embedding: {cosine_distance: {vector: [1, 0, 0], threshold: 0.01}}}) { id } }";
let (status, resp) = post_graphql(router, &json!({"query": query})).await;
assert_eq!(status, 200, "{resp}");
let mut got = ids(&resp);
got.sort_unstable();
assert_eq!(
got,
vec![1, 2],
"rows within the distance threshold, executed against real pgvector: {resp}"
);
}
#[tokio::test]
async fn inner_product_threshold_keeps_rows_at_least_that_similar() {
if skip("ip_threshold") {
return;
}
let router = setup().await.unwrap();
let query = r"{ docs(where: {embedding: {inner_product: {vector: [1, 0, 0], threshold: 0.5}}}) { id } }";
let (status, resp) = post_graphql(router, &json!({"query": query})).await;
assert_eq!(status, 200, "{resp}");
let mut got = ids(&resp);
got.sort_unstable();
assert_eq!(got, vec![1, 2], "minimum-inner-product semantics: {resp}");
}
#[tokio::test]
async fn nearest_on_a_bit_vector_orders_by_hamming_distance() {
if skip("nearest_hamming") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ bitDocs(nearest: {vector: "11110000", k: 4}) { id fingerprint } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "bitDocs"),
vec![2, 4, 1, 3],
"hamming counts differing bits: 0, 3, 4, 6 — {resp}"
);
assert_eq!(
resp["data"]["bitDocs"][0]["fingerprint"].as_str(),
Some("11110000"),
"a BitVector field is delivered as its bit string: {resp}"
);
}
#[tokio::test]
async fn jaccard_orders_the_same_bits_differently_than_hamming() {
if skip("nearest_jaccard") {
return;
}
let router = setup().await.unwrap();
let overridden =
r#"{ bitDocs(nearest: {vector: "11110000", k: 4, metric: "jaccard"}) { id } }"#;
let (status, resp) = post_graphql(router.clone(), &json!({"query": overridden})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "bitDocs"),
vec![2, 1, 4, 3],
"jaccard normalises by the union of set bits: 0, 0.5, 0.75, 1 — {resp}"
);
let declared = r#"{ jacDocs(nearest: {vector: "11110000", k: 4}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": declared})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "jacDocs"),
vec![2, 1, 4, 3],
"the field's declared metric applies when `metric:` is omitted: {resp}"
);
}
#[tokio::test]
async fn binary_distance_threshold_filters_return_the_rows_within_them() {
if skip("bit_thresholds") {
return;
}
let router = setup().await.unwrap();
let hamming = r#"{ bitDocs(where: {fingerprint: {hamming_distance: {vector: "11110000", threshold: 3}}}) { id } }"#;
let (status, resp) = post_graphql(router.clone(), &json!({"query": hamming})).await;
assert_eq!(status, 200, "{resp}");
let mut got = ids_under(&resp, "bitDocs");
got.sort_unstable();
assert_eq!(got, vec![2, 4], "within 3 differing bits, inclusive: {resp}");
let jaccard = r#"{ bitDocs(where: {fingerprint: {jaccard_distance: {vector: "11110000", threshold: 0.5}}}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": jaccard})).await;
assert_eq!(status, 200, "{resp}");
let mut got = ids_under(&resp, "bitDocs");
got.sort_unstable();
assert_eq!(got, vec![1, 2], "the same threshold selects a different pair: {resp}");
}
#[tokio::test]
async fn nearest_bit_dimension_mismatch_is_refused() {
if skip("nearest_bit_dims") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ bitDocs(nearest: {vector: "1010", k: 2}) { id } }"#;
let (_status, resp) = post_graphql(router, &json!({"query": q})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("4 bits") && msg.contains("8 dimensions"),
"the refusal names both sides of the mismatch: {resp}"
);
}
#[tokio::test]
async fn a_metric_of_the_other_vector_kind_is_refused() {
if skip("metric_kind_mismatch") {
return;
}
let router = setup().await.unwrap();
let float_on_bits =
r#"{ bitDocs(nearest: {vector: "11110000", k: 2, metric: "cosine"}) { id } }"#;
let (_status, resp) = post_graphql(router.clone(), &json!({"query": float_on_bits})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("float") && msg.contains("BitVector") && msg.contains("hamming"),
"the refusal names the field's kind and its metrics: {resp}"
);
let bits_on_float = r#"{ docs(nearest: {vector: [1, 0, 0], k: 2, metric: "hamming"}) { id } }"#;
let (_status, resp) = post_graphql(router, &json!({"query": bits_on_float})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("binary") && msg.contains("cosine"),
"and the same in reverse on a float Vector field: {resp}"
);
}
#[tokio::test]
async fn nearest_on_a_bit_vector_refuses_a_float_array_operand() {
if skip("bit_operand_shape") {
return;
}
let router = setup().await.unwrap();
let q = r"{ bitDocs(nearest: {vector: [1, 0, 1, 0, 0, 0, 0, 0], k: 2}) { id } }";
let (_status, resp) = post_graphql(router, &json!({"query": q})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("BitVector") && msg.contains("string"),
"the refusal names the operand shape a BitVector field takes: {resp}"
);
}
#[tokio::test]
async fn nearest_field_searches_the_named_vector_field() {
if skip("nearest_field_selector") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ multiDocs(nearest: {vector: [0, 0], k: 4, field: "thumbnail"}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "multiDocs"),
vec![4, 3, 2, 1],
"`thumbnail` orders by distance from the origin: [1,0] is nearest — {resp}"
);
let router = setup().await.unwrap();
let q = r#"{ multiDocs(nearest: {vector: [1, 0, 0], k: 4, field: "embedding"}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "multiDocs"),
vec![1, 2, 3, 4],
"`embedding` keeps its own cosine order — {resp}"
);
}
#[tokio::test]
async fn nearest_field_validates_the_selected_fields_dimensions() {
if skip("nearest_field_dimensions") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ multiDocs(nearest: {vector: [1, 0, 0], k: 2, field: "thumbnail"}) { id } }"#;
let (_, resp) = post_graphql(router, &json!({"query": q})).await;
let message = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
message.contains("3 components") && message.contains("2 dimensions"),
"the refusal must name the selected field's dimensions: {resp}"
);
}
#[tokio::test]
async fn nearest_without_a_field_on_a_multi_vector_type_is_refused() {
if skip("nearest_field_ambiguous") {
return;
}
let router = setup().await.unwrap();
let q = r"{ multiDocs(nearest: {vector: [1, 0, 0], k: 2}) { id } }";
let (_, resp) = post_graphql(router, &json!({"query": q})).await;
let message = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(message.contains("nearest.field"), "the refusal must name the way out: {resp}");
assert!(
message.contains("embedding") && message.contains("thumbnail"),
"the refusal must name the candidates: {resp}"
);
}
#[tokio::test]
async fn a_vector_distance_field_carries_the_distance_the_rows_were_ordered_by() {
if skip("distance_in_response") {
return;
}
let router = setup().await.unwrap();
let q = r"{ docs(nearest: {vector: [1, 0, 0], k: 4}) { id similarity } }";
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
let rows = resp["data"]["docs"].as_array().expect("rows");
let got: Vec<(i64, f64)> = rows
.iter()
.map(|r| (r["id"].as_i64().expect("id"), r["similarity"].as_f64().expect("similarity")))
.collect();
assert_eq!(got.len(), 4, "{resp}");
let expected = [(1_i64, 0.0_f64), (2, 2e-6), (3, 0.0194), (4, 1.0)];
for ((id, distance), (want_id, want)) in got.iter().zip(expected) {
assert_eq!(*id, want_id, "row order is the distance order: {resp}");
assert!(
(distance - want).abs() < 1e-3,
"row {id}: distance {distance} is not the cosine distance {want}: {resp}"
);
}
}
#[tokio::test]
async fn the_reported_distance_follows_the_metric_the_search_used() {
if skip("distance_follows_metric") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ docs(nearest: {vector: [1, 0, 0], k: 1, metric: "l2"}) { id similarity } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(resp["data"]["docs"][0]["id"].as_i64(), Some(1), "{resp}");
let d = resp["data"]["docs"][0]["similarity"].as_f64().expect("similarity");
assert!(d.abs() < 1e-6, "L2 distance from a vector to itself is 0, got {d}: {resp}");
let router = setup().await.unwrap();
let q = r#"{ docs(nearest: {vector: [1, 0, 0], k: 1, metric: "inner_product"}) { id similarity } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(resp["data"]["docs"][0]["id"].as_i64(), Some(2), "{resp}");
let d = resp["data"]["docs"][0]["similarity"].as_f64().expect("similarity");
assert!(
(d + 5.0).abs() < 1e-6,
"pgvector's <#> returns the negated inner product, -5 for [5,0.01,0], got {d}: {resp}"
);
}
#[tokio::test]
async fn a_distance_field_without_a_nearest_search_is_refused() {
if skip("distance_without_nearest") {
return;
}
let router = setup().await.unwrap();
let q = r"{ docs(limit: 2) { id similarity } }";
let (_status, resp) = post_graphql(router, &json!({"query": q})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("similarity") && msg.contains("nearest"),
"the refusal names the field and the way out: {resp}"
);
}
#[tokio::test]
async fn a_binary_search_reports_its_own_metrics_distance() {
if skip("bit_distance_in_response") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ bitDocs(nearest: {vector: "11110000", k: 4}) { id bitDistance } }"#;
let (status, resp) = post_graphql(router.clone(), &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
let got: Vec<(i64, f64)> = resp["data"]["bitDocs"]
.as_array()
.expect("rows")
.iter()
.map(|r| (r["id"].as_i64().expect("id"), r["bitDistance"].as_f64().expect("distance")))
.collect();
assert_eq!(got, vec![(2, 0.0), (4, 3.0), (1, 4.0), (3, 6.0)], "hamming counts bits: {resp}");
let q = r#"{ jacDocs(nearest: {vector: "11110000", k: 2}) { id bitDistance } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
let second = resp["data"]["jacDocs"][1]["bitDistance"].as_f64().expect("distance");
assert!(
(second - 0.5).abs() < 1e-9,
"jaccard is a ratio, not a bit count: id1 shares 4 of 8 set bits — {resp}"
);
}
#[tokio::test]
async fn a_distance_field_for_another_vector_field_is_refused() {
if skip("distance_field_mismatch") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ multiDocs(nearest: {vector: [0, 0], k: 2, field: "thumbnail"}) { id } }"#;
let (status, resp) = post_graphql(router.clone(), &json!({"query": q})).await;
assert_eq!(status, 200, "the control query works: {resp}");
let q = r#"{ multiDocs(nearest: {vector: [0, 0], k: 2, field: "thumbnail"}) { id thumbnailDistance } }"#;
let (_status, resp) = post_graphql(router, &json!({"query": q})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("embedding") && msg.contains("thumbnail"),
"the refusal names both the declared field and the searched one: {resp}"
);
}
#[tokio::test]
async fn a_distance_survives_the_full_row_path_a_gated_field_forces() {
struct AllowAll;
impl fraiseql_core::security::FieldAuthorizer for AllowAll {
fn authorize_field(
&self,
_req: &fraiseql_core::security::FieldAuthzRequest<'_>,
) -> fraiseql_core::error::Result<fraiseql_core::security::FieldAuthzDecision> {
Ok(fraiseql_core::security::FieldAuthzDecision::Allow)
}
}
if skip("distance_with_gated_field") {
return;
}
let url = try_database_url().unwrap();
let adapter = PostgresAdapter::new(&url).await.expect("connect to the test database");
seed(&adapter).await;
let config =
fraiseql_core::runtime::RuntimeConfig::default().with_field_authorizer(Arc::new(AllowAll));
let executor = Executor::with_config(schema(), Arc::new(adapter), config);
let security = fraiseql_core::security::SecurityContext {
user_id: "user-1".into(),
roles: vec!["admin".to_string()],
tenant_id: None,
scopes: vec![],
attributes: std::collections::HashMap::default(),
request_id: "req-vector".to_string(),
ip_address: None,
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
authenticated_at: chrono::Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
};
let q = r"{ docs(nearest: {vector: [1, 0, 0], k: 2}) { id title gated similarity } }";
let resp = executor
.execute_with_security(q, None, &security)
.await
.expect("the gated-field read must succeed");
let rows = resp["data"]["docs"].as_array().expect("rows");
assert_eq!(rows.len(), 2, "{resp}");
assert_eq!(rows[0]["id"].as_i64(), Some(1), "{resp}");
assert_eq!(
rows[0]["title"].as_str(),
Some("exact"),
"the stored row survives the concatenation: {resp}"
);
let d = rows[0]["similarity"]
.as_f64()
.unwrap_or_else(|| panic!("similarity must be projected: {resp}"));
assert!(d.abs() < 1e-9, "and the distance is added to it, not instead of it: {resp}");
let d1 = rows[1]["similarity"].as_f64().expect("similarity");
assert!(d1 > 0.0 && d1 < 1e-5, "row 2's cosine distance is ~2e-6, got {d1}: {resp}");
}
#[tokio::test]
async fn nearest_searches_a_half_precision_vector() {
if skip("nearest_halfvec") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ compactDocs(nearest: {vector: [1, 0, 0], k: 4, field: "compact"}) { id compactDistance } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "compactDocs"),
vec![1, 2, 3, 4],
"cosine order over halfvec matches the same vectors stored as `vector`: {resp}"
);
let d = resp["data"]["compactDocs"][0]["compactDistance"].as_f64().expect("distance");
assert!(d.abs() < 1e-6, "the exact match is at distance 0, got {d}: {resp}");
}
#[tokio::test]
async fn nearest_searches_a_sparse_vector() {
if skip("nearest_sparsevec") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ compactDocs(nearest: {vector: "{1:1}/5", k: 4, field: "terms"}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "compactDocs"),
vec![1, 3, 4, 2],
"the sparse column's declared L2 metric, not the cosine order: {resp}"
);
}
#[tokio::test]
async fn a_sparse_vector_refuses_a_dense_operand_and_a_wrong_dimension() {
if skip("sparse_operand_shape") {
return;
}
let router = setup().await.unwrap();
let dense =
r#"{ compactDocs(nearest: {vector: [1, 0, 0, 0, 0], k: 2, field: "terms"}) { id } }"#;
let (_status, resp) = post_graphql(router.clone(), &json!({"query": dense})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("SparseVector") && msg.contains("not an array"),
"the refusal names the sparse form: {resp}"
);
let wrong = r#"{ compactDocs(nearest: {vector: "{1:1}/9", k: 2, field: "terms"}) { id } }"#;
let (_status, resp) = post_graphql(router.clone(), &json!({"query": wrong})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(
msg.contains("9 dimensions") && msg.contains("5 dimensions"),
"the refusal names both dimension counts: {resp}"
);
let out_of_range =
r#"{ compactDocs(nearest: {vector: "{9:1}/5", k: 2, field: "terms"}) { id } }"#;
let (_status, resp) = post_graphql(router, &json!({"query": out_of_range})).await;
let msg = resp["errors"][0]["message"].as_str().unwrap_or_default();
assert!(msg.contains("outside 1..=5"), "an index past the dimension count: {resp}");
}
#[tokio::test]
async fn a_sparse_threshold_filter_returns_the_rows_within_it() {
if skip("sparse_threshold") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ compactDocs(where: {terms: {l2_distance: {vector: "{1:1}/5", threshold: 1.0}}}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
let mut got = ids_under(&resp, "compactDocs");
got.sort_unstable();
assert_eq!(got, vec![1, 3], "rows within L2 distance 1 of the query: {resp}");
}
#[tokio::test]
async fn a_dense_threshold_filter_reads_the_native_column_not_the_payload() {
if skip("skew_dense_threshold") {
return;
}
let router = setup().await.unwrap();
let q = r"{ skewDocs(where: {embedding: {cosine_distance: {vector: [1, 0, 0], threshold: 0.01}}}) { id } }";
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "skewDocs"),
vec![10],
"id10 is near in the COLUMN and far in the payload; id11 is the reverse: {resp}"
);
}
#[tokio::test]
async fn a_dense_threshold_filter_excludes_the_row_only_the_payload_makes_near() {
if skip("skew_dense_threshold_mirror") {
return;
}
let router = setup().await.unwrap();
let q = r"{ skewDocs(where: {embedding: {cosine_distance: {vector: [0, 1, 0], threshold: 0.01}}}) { id } }";
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "skewDocs"),
vec![11],
"against [0,1,0] the column makes id11 the near row, and the payload id10: {resp}"
);
}
#[tokio::test]
async fn a_binary_threshold_filter_reads_the_native_bit_column_not_the_payload() {
if skip("skew_bit_threshold") {
return;
}
let router = setup().await.unwrap();
let q = r#"{ skewBitDocs(where: {fingerprint: {hamming_distance: {vector: "11110000", threshold: 1.0}}}) { id } }"#;
let (status, resp) = post_graphql(router, &json!({"query": q})).await;
assert_eq!(status, 200, "{resp}");
assert_eq!(
ids_under(&resp, "skewBitDocs"),
vec![10],
"the bit predicate reads the `fingerprint` column, not data->>'fingerprint': {resp}"
);
}