use std::sync::Arc;
use crate::contract::{HasSchema, ModelSchema};
use crate::contract_validator::{validate_schema, ReportStatus, SchemaReport};
use crate::orm::Db;
use crate::search::{Indexer, MeiliClient};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchConfig {
pub index: &'static str,
pub primary_key: &'static str,
pub searchable_attributes: Vec<&'static str>,
pub filterable_attributes: Vec<&'static str>,
pub sortable_attributes: Vec<&'static str>,
}
pub fn search_config_from_schema(schema: &ModelSchema) -> Option<SearchConfig> {
let index = schema.search_index?;
Some(SearchConfig {
index,
primary_key: schema.primary_key,
searchable_attributes: schema
.columns
.iter()
.filter(|c| c.flags.searchable)
.map(|c| c.name)
.collect(),
filterable_attributes: schema
.columns
.iter()
.filter(|c| c.flags.filterable)
.map(|c| c.name)
.collect(),
sortable_attributes: schema
.columns
.iter()
.filter(|c| c.flags.sortable)
.map(|c| c.name)
.collect(),
})
}
#[derive(Debug, Clone)]
pub enum SearchEnablement {
NotSearchable,
Disabled { report: SchemaReport },
Enabled {
config: SearchConfig,
report: SchemaReport,
},
}
impl SearchEnablement {
pub fn is_enabled(&self) -> bool {
matches!(self, SearchEnablement::Enabled { .. })
}
pub fn config(&self) -> Option<&SearchConfig> {
match self {
SearchEnablement::Enabled { config, .. } => Some(config),
_ => None,
}
}
pub fn report(&self) -> Option<&SchemaReport> {
match self {
SearchEnablement::NotSearchable => None,
SearchEnablement::Disabled { report }
| SearchEnablement::Enabled { report, .. } => Some(report),
}
}
}
pub async fn enable_search<M: HasSchema>(db: &Db) -> SearchEnablement {
let schema = M::SCHEMA;
let report = validate_schema::<M>(db).await;
enablement_from(&schema, report)
}
pub fn enablement_from(schema: &ModelSchema, report: SchemaReport) -> SearchEnablement {
match report.status {
ReportStatus::Error => SearchEnablement::Disabled { report },
ReportStatus::Ok | ReportStatus::Warning => match search_config_from_schema(schema) {
Some(config) => SearchEnablement::Enabled { config, report },
None => SearchEnablement::NotSearchable,
},
}
}
pub async fn indexer_from_schema<T: HasSchema>(
client: Arc<MeiliClient>,
db: &Db,
capacity: usize,
) -> Option<Indexer> {
let outcome = enable_search::<T>(db).await;
match outcome {
SearchEnablement::Enabled { config, report } => {
for w in &report.warnings {
log::warn!(
"search: schema warning on `{}`: {}",
report.table, w.message
);
}
let searchable: Vec<&str> = config.searchable_attributes.to_vec();
let filterable: Vec<&str> = config.filterable_attributes.to_vec();
let sortable: Vec<&str> = config.sortable_attributes.to_vec();
if let Err(e) = client
.configure_index(config.index, &searchable, &filterable, &sortable)
.await
{
log::warn!(
"search: configure_index({}) failed at startup: {e} \
(indexer still spawned; documents will queue)",
config.index
);
} else {
log::info!(
"search: index `{}` configured (searchable={} filterable={} sortable={})",
config.index,
searchable.len(),
filterable.len(),
sortable.len()
);
}
Some(Indexer::spawn(client, capacity))
}
SearchEnablement::Disabled { report } => {
log::warn!(
"search: disabled for `{}` — validator reported {} error(s); \
indexer NOT spawned (fail-safe)",
report.table,
report.errors.len()
);
None
}
SearchEnablement::NotSearchable => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{ModelColumn, RustType, SchemaFlags};
use crate::contract_validator::{IssueKind, SchemaIssue};
fn fixture_schema() -> ModelSchema {
static COLS: &[ModelColumn] = &[
ModelColumn {
name: "id",
sql_decl: "BIGSERIAL PRIMARY KEY",
rust_type: RustType::I64,
nullable: false,
primary_key: true,
flags: SchemaFlags {
searchable: false,
filterable: false,
sortable: true,
readonly: true,
},
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "title",
sql_decl: "TEXT NOT NULL",
rust_type: RustType::String,
nullable: false,
primary_key: false,
flags: SchemaFlags {
searchable: true,
filterable: true,
sortable: false,
readonly: false,
},
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "body",
sql_decl: "TEXT",
rust_type: RustType::String,
nullable: true,
primary_key: false,
flags: SchemaFlags {
searchable: true,
filterable: false,
sortable: false,
readonly: false,
},
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "internal_note",
sql_decl: "TEXT",
rust_type: RustType::String,
nullable: true,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "published_at",
sql_decl: "TIMESTAMPTZ",
rust_type: RustType::DateTimeUtc,
nullable: true,
primary_key: false,
flags: SchemaFlags {
searchable: false,
filterable: true,
sortable: true,
readonly: false,
},
admin_label: None,
admin_widget: None,
},
];
ModelSchema {
table: "posts",
columns: COLS,
primary_key: "id",
search_index: Some("posts"),
}
}
fn fixture_unsearchable_schema() -> ModelSchema {
static COLS: &[ModelColumn] = &[ModelColumn {
name: "id",
sql_decl: "BIGSERIAL PRIMARY KEY",
rust_type: RustType::I64,
nullable: false,
primary_key: true,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
}];
ModelSchema {
table: "audit_logs",
columns: COLS,
primary_key: "id",
search_index: None,
}
}
fn fixture_empty_searchable_schema() -> ModelSchema {
static COLS: &[ModelColumn] = &[
ModelColumn {
name: "id",
sql_decl: "BIGSERIAL PRIMARY KEY",
rust_type: RustType::I64,
nullable: false,
primary_key: true,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
ModelColumn {
name: "value",
sql_decl: "TEXT NOT NULL",
rust_type: RustType::String,
nullable: false,
primary_key: false,
flags: SchemaFlags::empty(),
admin_label: None,
admin_widget: None,
},
];
ModelSchema {
table: "items",
columns: COLS,
primary_key: "id",
search_index: Some("items"),
}
}
fn ok_report(table: &str) -> SchemaReport {
SchemaReport {
table: table.to_string(),
status: ReportStatus::Ok,
errors: vec![],
warnings: vec![],
}
}
fn warning_report(table: &str) -> SchemaReport {
SchemaReport {
table: table.to_string(),
status: ReportStatus::Warning,
errors: vec![],
warnings: vec![SchemaIssue {
column: Some("legacy_code".into()),
kind: IssueKind::ExtraDbColumn,
message: "extra DB column `legacy_code` not declared in Rust contract".into(),
expected: None,
actual: Some("legacy_code".into()),
}],
}
}
fn error_report(table: &str) -> SchemaReport {
SchemaReport {
table: table.to_string(),
status: ReportStatus::Error,
errors: vec![SchemaIssue {
column: Some("amount".into()),
kind: IssueKind::MissingColumn,
message: "column `posts.amount` declared in Rust contract not present in database"
.into(),
expected: Some("NUMERIC NOT NULL".into()),
actual: None,
}],
warnings: vec![],
}
}
#[test]
fn searchable_attributes_drawn_only_from_flagged_columns() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).expect("schema is searchable");
assert_eq!(cfg.searchable_attributes, vec!["title", "body"]);
for excluded in ["id", "internal_note", "published_at"] {
assert!(
!cfg.searchable_attributes.contains(&excluded),
"column `{excluded}` should not appear in searchable_attributes"
);
}
}
#[test]
fn non_searchable_fields_excluded_from_search_list() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).unwrap();
for list_name in [
("searchable", &cfg.searchable_attributes),
("filterable", &cfg.filterable_attributes),
("sortable", &cfg.sortable_attributes),
] {
let (name, list) = list_name;
assert!(
!list.contains(&"internal_note"),
"internal_note must be excluded from {name}"
);
}
}
#[test]
fn ordering_preserved_within_searchable_attributes() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).unwrap();
let title_idx = cfg.searchable_attributes.iter().position(|s| *s == "title");
let body_idx = cfg.searchable_attributes.iter().position(|s| *s == "body");
assert_eq!(title_idx, Some(0));
assert_eq!(body_idx, Some(1));
}
#[test]
fn ordering_preserved_within_filterable_and_sortable() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).unwrap();
assert_eq!(cfg.filterable_attributes, vec!["title", "published_at"]);
assert_eq!(cfg.sortable_attributes, vec!["id", "published_at"]);
}
#[test]
fn empty_searchable_set_yields_empty_lists_not_panic() {
let schema = fixture_empty_searchable_schema();
let cfg = search_config_from_schema(&schema).expect("search_index is set");
assert_eq!(cfg.index, "items");
assert_eq!(cfg.primary_key, "id");
assert!(cfg.searchable_attributes.is_empty());
assert!(cfg.filterable_attributes.is_empty());
assert!(cfg.sortable_attributes.is_empty());
}
#[test]
fn schema_with_no_search_index_yields_none() {
let schema = fixture_unsearchable_schema();
assert!(search_config_from_schema(&schema).is_none());
}
#[test]
fn search_disabled_when_validator_returns_errors() {
let schema = fixture_schema();
let report = error_report(schema.table);
let outcome = enablement_from(&schema, report.clone());
match outcome {
SearchEnablement::Disabled { report: r } => {
assert_eq!(r, report);
assert_eq!(r.status, ReportStatus::Error);
}
other => panic!("expected Disabled, got {:?}", other),
}
let outcome = enablement_from(&schema, error_report(schema.table));
assert!(!outcome.is_enabled());
assert!(outcome.config().is_none());
assert!(outcome.report().is_some());
}
#[test]
fn search_allowed_when_validator_returns_warnings_only() {
let schema = fixture_schema();
let report = warning_report(schema.table);
let outcome = enablement_from(&schema, report);
match outcome {
SearchEnablement::Enabled { config, report: r } => {
assert_eq!(r.status, ReportStatus::Warning);
assert_eq!(config.index, "posts");
assert_eq!(config.searchable_attributes, vec!["title", "body"]);
}
other => panic!("expected Enabled, got {:?}", other),
}
}
#[test]
fn search_enabled_when_validator_returns_ok() {
let schema = fixture_schema();
let outcome = enablement_from(&schema, ok_report(schema.table));
match outcome {
SearchEnablement::Enabled { config, report } => {
assert_eq!(report.status, ReportStatus::Ok);
assert_eq!(config.index, "posts");
assert_eq!(config.primary_key, "id");
assert_eq!(config.searchable_attributes, vec!["title", "body"]);
assert_eq!(config.filterable_attributes, vec!["title", "published_at"]);
assert_eq!(config.sortable_attributes, vec!["id", "published_at"]);
}
other => panic!("expected Enabled, got {:?}", other),
}
}
#[test]
fn unsearchable_schema_short_circuits_to_not_searchable() {
let schema = fixture_unsearchable_schema();
let outcome = enablement_from(&schema, ok_report(schema.table));
match outcome {
SearchEnablement::NotSearchable => {}
other => panic!("expected NotSearchable, got {:?}", other),
}
let outcome = enablement_from(&schema, ok_report(schema.table));
assert!(!outcome.is_enabled());
assert!(outcome.config().is_none());
assert!(outcome.report().is_none(), "NotSearchable carries no report");
}
#[test]
fn search_config_carries_schema_index_and_primary_key() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).unwrap();
assert_eq!(cfg.index, "posts");
assert_eq!(cfg.primary_key, "id");
}
#[test]
fn search_config_lists_are_static_str_borrowable_as_str_slices() {
let schema = fixture_schema();
let cfg = search_config_from_schema(&schema).unwrap();
fn assert_static_strs(_: &[&'static str]) {}
assert_static_strs(&cfg.searchable_attributes);
assert_static_strs(&cfg.filterable_attributes);
assert_static_strs(&cfg.sortable_attributes);
}
#[test]
fn indexer_from_schema_symbol_visible() {
let _f = super::indexer_from_schema::<DummyHasSchema>;
}
struct DummyHasSchema;
impl crate::contract::HasSchema for DummyHasSchema {
const SCHEMA: ModelSchema = ModelSchema {
table: "dummy",
columns: &[],
primary_key: "id",
search_index: None,
};
}
#[test]
fn enablement_accessor_invariants() {
let schema = fixture_schema();
let enabled = enablement_from(&schema, ok_report("posts"));
let disabled = enablement_from(&schema, error_report("posts"));
let none = enablement_from(&fixture_unsearchable_schema(), ok_report("audit_logs"));
assert!(enabled.is_enabled());
assert!(!disabled.is_enabled());
assert!(!none.is_enabled());
assert!(enabled.config().is_some());
assert!(disabled.config().is_none());
assert!(none.config().is_none());
}
}