#![allow(clippy::unwrap_used)]
use fraiseql_error::FraiseQLError;
use super::{
HnswIterativeScan, IvfflatIterativeScan, PoolPrewarmConfig, PostgresAdapter, PostgresTlsConfig,
SearchPath, VectorScanConfig, build_projection_select_sql, build_where_select_sql,
build_where_select_sql_ordered, compose_startup_options,
};
#[test]
fn test_build_where_select_sql_no_clause() {
let (sql, params) = build_where_select_sql("v_user", None, None, None).unwrap();
assert_eq!(sql, r#"SELECT data FROM "v_user""#);
assert!(params.is_empty());
}
#[test]
fn test_build_where_select_sql_with_limit_offset() {
let (sql, params) = build_where_select_sql("v_order", None, Some(10), Some(20)).unwrap();
assert!(sql.contains("LIMIT $1"), "expected LIMIT $1 in: {sql}");
assert!(sql.contains("OFFSET $2"), "expected OFFSET $2 in: {sql}");
assert_eq!(params.len(), 2, "expected 2 params (limit + offset)");
}
#[test]
fn pool_prewarm_config_carries_all_fields() {
let cfg = PoolPrewarmConfig {
min_size: 5,
max_size: 20,
timeout_secs: Some(30),
search_path: None,
tls: PostgresTlsConfig::default(),
read_replicas: None,
max_streaming_reads: None,
vector_scan: VectorScanConfig::default(),
};
assert_eq!(cfg.min_size, 5);
assert_eq!(cfg.max_size, 20);
assert_eq!(cfg.timeout_secs, Some(30));
}
#[test]
fn pool_prewarm_config_no_timeout_is_none() {
let cfg = PoolPrewarmConfig {
min_size: 0,
max_size: 10,
timeout_secs: None,
search_path: None,
tls: PostgresTlsConfig::default(),
read_replicas: None,
max_streaming_reads: None,
vector_scan: VectorScanConfig::default(),
};
assert!(cfg.timeout_secs.is_none());
}
#[test]
fn pool_prewarm_config_min_zero_is_valid() {
let cfg = PoolPrewarmConfig {
min_size: 0,
max_size: 5,
timeout_secs: None,
search_path: None,
tls: PostgresTlsConfig::default(),
read_replicas: None,
max_streaming_reads: None,
vector_scan: VectorScanConfig::default(),
};
assert_eq!(cfg.min_size, 0);
assert_eq!(cfg.max_size, 5);
}
#[test]
fn pool_prewarm_config_min_equals_max_is_valid() {
let cfg = PoolPrewarmConfig {
min_size: 10,
max_size: 10,
timeout_secs: Some(60),
search_path: None,
tls: PostgresTlsConfig::default(),
read_replicas: None,
max_streaming_reads: None,
vector_scan: VectorScanConfig::default(),
};
assert_eq!(cfg.min_size, cfg.max_size);
}
#[test]
fn search_path_joins_schemas_in_order() {
let path = SearchPath::new(["tenant_acme", "public"]).unwrap();
assert_eq!(path.as_str(), "tenant_acme,public");
}
#[test]
fn search_path_rejects_non_identifiers() {
for bad in [
"public; DROP SCHEMA x",
"a b",
"\"quoted\"",
"1leading",
"",
"with-dash",
] {
assert!(SearchPath::new([bad]).is_err(), "`{bad}` must be refused");
}
}
#[test]
fn search_path_rejects_an_empty_list() {
assert!(SearchPath::new(Vec::<&str>::new()).is_err());
}
#[test]
fn search_path_rejects_an_over_long_identifier() {
assert!(SearchPath::new(["a".repeat(64)]).is_err());
assert!(SearchPath::new(["a".repeat(63)]).is_ok());
}
const NO_VECTOR_SCAN: VectorScanConfig = VectorScanConfig {
hnsw: HnswIterativeScan::Off,
ivfflat: IvfflatIterativeScan::Off,
ef_search: None,
};
#[test]
fn startup_options_are_composed_when_the_url_carries_none() {
let path = SearchPath::new(["tenant_acme", "public"]).unwrap();
assert_eq!(
compose_startup_options("postgres://u:p@h:5432/db", Some(&path), NO_VECTOR_SCAN),
Some("-c search_path=tenant_acme,public".to_string())
);
}
#[test]
fn startup_options_preserve_operator_supplied_options() {
let path = SearchPath::new(["tenant_acme", "public"]).unwrap();
let composed = compose_startup_options(
"postgres://u:p@h:5432/db?options=-c%20statement_timeout%3D5000",
Some(&path),
NO_VECTOR_SCAN,
);
assert_eq!(
composed,
Some("-c statement_timeout=5000 -c search_path=tenant_acme,public".to_string())
);
}
#[test]
fn startup_options_tolerate_an_unparseable_url() {
let path = SearchPath::new(["tenant_acme"]).unwrap();
assert_eq!(
compose_startup_options("not-a-postgres-url", Some(&path), NO_VECTOR_SCAN),
Some("-c search_path=tenant_acme".to_string())
);
}
#[test]
fn vector_scan_settings_ride_the_startup_packet_without_a_search_path() {
assert_eq!(
compose_startup_options("postgres://u:p@h:5432/db", None, VectorScanConfig::default()),
Some(
"-c hnsw.iterative_scan=strict_order -c ivfflat.iterative_scan=relaxed_order"
.to_string()
)
);
}
#[test]
fn the_two_indexes_default_to_the_strongest_guarantee_each_defines() {
assert_eq!(HnswIterativeScan::default(), HnswIterativeScan::StrictOrder);
assert_eq!(IvfflatIterativeScan::default(), IvfflatIterativeScan::RelaxedOrder);
}
#[test]
fn turning_the_settings_off_writes_nothing_rather_than_forcing_off() {
assert_eq!(NO_VECTOR_SCAN.startup_fragments(), Vec::<String>::new());
assert_eq!(compose_startup_options("postgres://u:p@h/db", None, NO_VECTOR_SCAN), None);
}
#[test]
fn each_index_contributes_its_own_fragment() {
let hnsw_only = VectorScanConfig {
hnsw: HnswIterativeScan::RelaxedOrder,
ivfflat: IvfflatIterativeScan::Off,
ef_search: None,
};
assert_eq!(hnsw_only.startup_fragments(), vec!["-c hnsw.iterative_scan=relaxed_order"]);
let ivfflat_only = VectorScanConfig {
hnsw: HnswIterativeScan::Off,
ivfflat: IvfflatIterativeScan::RelaxedOrder,
ef_search: None,
};
assert_eq!(
ivfflat_only.startup_fragments(),
vec!["-c ivfflat.iterative_scan=relaxed_order"]
);
let ef_only = VectorScanConfig {
hnsw: HnswIterativeScan::Off,
ivfflat: IvfflatIterativeScan::Off,
ef_search: Some(200),
};
assert_eq!(ef_only.startup_fragments(), vec!["-c hnsw.ef_search=200"]);
}
#[test]
fn ef_search_is_unset_by_default_and_composes_with_the_scan_modes() {
assert_eq!(VectorScanConfig::default().ef_search, None);
assert!(
!VectorScanConfig::default()
.startup_fragments()
.iter()
.any(|f| f.contains("ef_search")),
"the default must not choose an ef_search for the operator"
);
let all = VectorScanConfig {
ef_search: Some(400),
..VectorScanConfig::default()
};
assert_eq!(
all.startup_fragments(),
vec![
"-c hnsw.iterative_scan=strict_order",
"-c ivfflat.iterative_scan=relaxed_order",
"-c hnsw.ef_search=400",
]
);
}
#[test]
fn the_search_path_the_operators_options_and_the_vector_settings_all_survive() {
let path = SearchPath::new(["tenant_acme"]).unwrap();
let composed = compose_startup_options(
"postgres://u:p@h:5432/db?options=-c%20statement_timeout%3D5000",
Some(&path),
VectorScanConfig::default(),
);
let expected = "-c statement_timeout=5000 \
-c search_path=tenant_acme \
-c hnsw.iterative_scan=strict_order \
-c ivfflat.iterative_scan=relaxed_order";
assert_eq!(composed.as_deref(), Some(expected));
}
#[tokio::test]
async fn test_new_with_malformed_url_returns_connection_pool_error() {
let result = PostgresAdapter::new("not-a-postgres-url").await;
assert!(result.is_err(), "expected error for malformed URL");
let err = result.expect_err("error confirmed above");
assert!(
matches!(err, FraiseQLError::ConnectionPool { .. }),
"expected ConnectionPool error for malformed URL, got: {err:?}"
);
}
#[tokio::test]
async fn test_with_pool_size_malformed_url_returns_connection_pool_error() {
let result = PostgresAdapter::with_pool_size("://bad-url", 1).await;
assert!(result.is_err(), "expected error for bad URL");
let err = result.expect_err("error confirmed above");
assert!(
matches!(err, FraiseQLError::ConnectionPool { .. }),
"expected ConnectionPool error for bad URL with custom pool size, got: {err:?}"
);
}
#[test]
fn changelog_cte_runs_the_function_once_and_inserts_the_outbox_row() {
let sql = super::database::build_changelog_cte_sql(r#""app"."fn_create_user""#, 2, false);
assert!(
sql.contains(r#"WITH r AS MATERIALIZED (SELECT * FROM "app"."fn_create_user"($1, $2))"#),
"function call must be a MATERIALIZED CTE: {sql}"
);
assert!(
sql.contains("INSERT INTO core.tb_entity_change_log"),
"must INSERT into the contract table: {sql}"
);
assert!(
sql.trim_end().ends_with("SELECT * FROM r"),
"must return the function row: {sql}"
);
}
#[test]
fn changelog_cte_threads_object_type_fallback_and_modification_type_after_args() {
let sql = super::database::build_changelog_cte_sql(r#""fn_x""#, 2, false);
assert!(
sql.contains("COALESCE(r.entity_type, $3)"),
"object_type falls back to $n+1 when entity_type is NULL: {sql}"
);
assert!(sql.contains("$4,"), "modification_type verb is $n+2: {sql}");
let zero = super::database::build_changelog_cte_sql(r#""fn_y""#, 0, false);
assert!(
zero.contains(r#"SELECT * FROM "fn_y"()"#),
"no-arg call has empty parens: {zero}"
);
assert!(
zero.contains("COALESCE(r.entity_type, $1)"),
"fallback is $1 with no args: {zero}"
);
assert!(zero.contains("$2,"), "verb is $2 with no args: {zero}");
}
#[test]
fn changelog_cte_threads_actor_envelope_after_trace_context() {
let sql = super::database::build_changelog_cte_sql(r#""fn_x""#, 2, false);
assert!(
sql.contains("actor_type, acting_for, commit_time)"),
"actor_type/acting_for INSERT columns thread before commit_time: {sql}"
);
assert!(sql.contains("$9,"), "actor_type is $n+7: {sql}");
assert!(sql.contains("$10::uuid,"), "acting_for is $n+8, bound as uuid: {sql}");
}
#[test]
fn changelog_cte_only_logs_effective_changes_and_stamps_the_duration_marker() {
let sql = super::database::build_changelog_cte_sql(r#""fn_z""#, 1, false);
assert!(
sql.contains("WHERE r.succeeded AND r.state_changed"),
"must gate on an effective change: {sql}"
);
assert!(sql.contains("EXTRACT(EPOCH"), "canonical duration computation: {sql}");
assert!(
!sql.to_uppercase().contains("MILLISECONDS"),
"must avoid the MILLISECONDS trap: {sql}"
);
assert!(
sql.contains("current_setting('fraiseql.started_at')"),
"reads started_at from the txn-local GUC: {sql}"
);
assert!(
sql.contains("'duration_calc_version', 2::int"),
"stamps the duration_calc_version marker: {sql}"
);
}
#[test]
fn changelog_cte_maps_mutation_response_columns_to_contract_columns() {
let sql = super::database::build_changelog_cte_sql(r#""fn_w""#, 0, false);
for needle in [
"(object_type, modification_type, object_id, object_data, updated_fields, cascade, \
started_at, duration_ms, extra_metadata, tenant_id, trace_id, schema_version, \
trace_context, actor_type, acting_for, commit_time)",
"r.entity_id, r.entity, r.updated_fields, r.cascade",
] {
assert!(sql.contains(needle), "expected `{needle}` in: {sql}");
}
}
#[test]
fn changelog_cte_stamps_the_envelope_tenant_commit_time_and_lets_seq_default() {
let sql = super::database::build_changelog_cte_sql(r#""fn_e""#, 2, false);
assert!(
sql.contains("$5::uuid"),
"tenant_id is the $n+3 envelope param, cast to uuid: {sql}"
);
assert!(sql.contains("$6,"), "trace_id is the $n+4 envelope param: {sql}");
assert!(sql.contains("$7,"), "schema_version is the $n+5 envelope param: {sql}");
assert!(
sql.contains("$8::jsonb"),
"trace_context is the $n+6 envelope param, cast jsonb: {sql}"
);
assert!(sql.contains("$9,"), "actor_type is the $n+7 envelope param: {sql}");
assert!(
sql.contains("$10::uuid"),
"acting_for is the $n+8 envelope param, cast uuid: {sql}"
);
assert!(
sql.contains("clock_timestamp()"),
"commit_time stamped with clock_timestamp(): {sql}"
);
assert!(
sql.contains("acting_for, commit_time)"),
"INSERT column list ends at commit_time, omitting seq for its DEFAULT: {sql}"
);
}
#[test]
fn changelog_cte_omits_object_data_before_when_pre_image_is_off() {
let sql = super::database::build_changelog_cte_sql(r#""fn_o""#, 2, false);
assert!(
!sql.contains("object_data_before"),
"pre_image=false must NOT add the object_data_before column: {sql}"
);
assert!(
!sql.contains("r.entity_before"),
"pre_image=false must NOT read r.entity_before: {sql}"
);
assert!(
sql.contains("object_id, object_data, updated_fields"),
"after-image object_data column is unchanged: {sql}"
);
assert!(
sql.contains("r.entity_id, r.entity, r.updated_fields"),
"after-image r.entity is unchanged: {sql}"
);
}
#[test]
fn changelog_cte_records_the_pre_image_when_opted_in() {
let sql = super::database::build_changelog_cte_sql(r#""fn_p""#, 2, true);
assert!(
sql.contains("object_id, object_data, object_data_before, updated_fields"),
"pre_image=true adds object_data_before AFTER object_data in the INSERT list: {sql}"
);
assert!(
sql.contains("r.entity_id, r.entity, r.entity_before, r.updated_fields"),
"pre_image=true selects r.entity_before AFTER r.entity (after-image unchanged): {sql}"
);
assert!(
sql.contains("COALESCE(r.entity_type, $3)"),
"object_type fallback still $n+1: {sql}"
);
assert!(sql.contains("$10::uuid"), "acting_for still $n+8: {sql}");
}
#[test]
fn pin_is_active_inside_the_window() {
assert!(super::pin_active(1_000, 900, 200), "100ms after a write with a 200ms pin");
}
#[test]
fn pin_expires_at_the_window_boundary() {
assert!(!super::pin_active(1_000, 900, 100), "exactly pin_ms after a write is unpinned");
}
#[test]
fn pin_is_inactive_when_never_written() {
assert!(!super::pin_active(1_700_000_000_000, 0, 5_000));
}
#[test]
fn pin_with_zero_window_never_activates() {
assert!(!super::pin_active(1_000, 1_000, 0), "pin_after_write = 0 disables pinning");
}
#[test]
fn pin_saturates_on_clock_regression() {
assert!(super::pin_active(900, 1_000, 100));
}
use crate::types::{
DatabaseType,
sql_hints::{OrderByClause, OrderDirection, SqlProjectionHint},
};
#[test]
fn build_where_select_sql_ordered_appends_the_identity() {
let clauses = [OrderByClause::new(
"status".to_string(),
OrderDirection::Asc,
)];
let (sql, _) =
build_where_select_sql_ordered("v_user", None, Some(10), Some(20), Some(&clauses)).unwrap();
assert!(
sql.contains("ORDER BY data->>'status' ASC, data->>'id' ASC"),
"a paginated read must order totally: {sql}"
);
assert!(sql.contains("LIMIT $1"), "{sql}");
assert!(sql.contains("OFFSET $2"), "{sql}");
}
#[test]
fn build_projection_select_sql_appends_the_identity() {
let hint = SqlProjectionHint {
database: DatabaseType::PostgreSQL,
projection_template: "jsonb_build_object('id', data->>'id')".to_string(),
estimated_reduction_percent: 50,
};
let clauses = [OrderByClause::new(
"status".to_string(),
OrderDirection::Asc,
)];
let (sql, _) =
build_projection_select_sql(&hint, "v_user", None, Some(10), None, Some(&clauses)).unwrap();
assert!(
sql.contains("ORDER BY data->>'status' ASC, data->>'id' ASC"),
"the projection path pages the same way and needs the same order: {sql}"
);
}
#[test]
fn an_unordered_paginated_read_is_still_unordered() {
let (sql, _) = build_where_select_sql_ordered("v_user", None, Some(10), None, None).unwrap();
assert!(!sql.contains("ORDER BY"), "{sql}");
}