search fix to new architecture2
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -6474,7 +6474,6 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
"prost",
|
"prost",
|
||||||
"rust_decimal",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|||||||
2
client
2
client
Submodule client updated: cbe1f9adc6...f8bc80a415
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use tantivy::schema::{
|
use tantivy::schema::{
|
||||||
Field, IndexRecordOption, JsonObjectOptions, Schema, Term, TextFieldIndexing, TextOptions,
|
Field, IndexRecordOption, JsonObjectOptions, Schema, Term, TextFieldIndexing, TextOptions,
|
||||||
INDEXED, STORED, STRING,
|
FAST, INDEXED, STORED, STRING,
|
||||||
};
|
};
|
||||||
use tantivy::tokenizer::{
|
use tantivy::tokenizer::{
|
||||||
AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer, RemoveLongFilter,
|
AsciiFoldingFilter, LowerCaser, NgramTokenizer, RawTokenizer, RemoveLongFilter,
|
||||||
@@ -11,6 +11,10 @@ use tantivy::tokenizer::{
|
|||||||
use tantivy::Index;
|
use tantivy::Index;
|
||||||
|
|
||||||
pub const F_PG_ID: &str = "pg_id";
|
pub const F_PG_ID: &str = "pg_id";
|
||||||
|
pub const F_ROW_REVISION: &str = "row_revision";
|
||||||
|
pub const F_IS_ARCHIVED: &str = "is_archived";
|
||||||
|
pub const F_TABLE_DEFINITION_ID: &str = "table_definition_id";
|
||||||
|
pub const F_VERSION_NUMBER: &str = "version_number";
|
||||||
pub const F_TABLE_NAME: &str = "table_name";
|
pub const F_TABLE_NAME: &str = "table_name";
|
||||||
pub const F_ROW_KEY: &str = "row_key";
|
pub const F_ROW_KEY: &str = "row_key";
|
||||||
pub const F_ALL_TEXT: &str = "all_text";
|
pub const F_ALL_TEXT: &str = "all_text";
|
||||||
@@ -19,7 +23,7 @@ pub const F_DATA_NGRAM: &str = "data_ngram";
|
|||||||
pub const F_DATA_EXACT: &str = "data_exact";
|
pub const F_DATA_EXACT: &str = "data_exact";
|
||||||
pub const JOURNAL_TABLE_NAME: &str = "general_ledger";
|
pub const JOURNAL_TABLE_NAME: &str = "general_ledger";
|
||||||
pub const ARCHIVED_ROW_KEY_PREFIX: &str = "__archive__:";
|
pub const ARCHIVED_ROW_KEY_PREFIX: &str = "__archive__:";
|
||||||
pub const SEARCH_INDEX_FORMAT_DIRECTORY: &str = "v2";
|
pub const SEARCH_INDEX_FORMAT_DIRECTORY: &str = "v3";
|
||||||
|
|
||||||
/// Root for the current on-disk index format. The format component prevents
|
/// Root for the current on-disk index format. The format component prevents
|
||||||
/// an executable with new projection semantics from opening old documents.
|
/// an executable with new projection semantics from opening old documents.
|
||||||
@@ -84,6 +88,38 @@ pub fn normalize_exact(input: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonicalizes an exact constraint with the same scalar spelling used by
|
||||||
|
/// the indexer. Catalog field types are public type names, not PostgreSQL type
|
||||||
|
/// names, so this deliberately matches the catalog vocabulary.
|
||||||
|
pub fn canonical_exact_search_value(input: &str, field_type: &str) -> Result<String, String> {
|
||||||
|
let normalized_type = field_type.trim().to_ascii_lowercase();
|
||||||
|
if matches!(normalized_type.as_str(), "numeric" | "money")
|
||||||
|
|| normalized_type.starts_with("decimal(")
|
||||||
|
{
|
||||||
|
return input
|
||||||
|
.parse::<rust_decimal::Decimal>()
|
||||||
|
.map(|value| value.normalize().to_string())
|
||||||
|
.map_err(|error| format!("Exact numeric search value is invalid: {error}"));
|
||||||
|
}
|
||||||
|
if matches!(normalized_type.as_str(), "int" | "bigint")
|
||||||
|
|| normalized_type.starts_with("link(")
|
||||||
|
{
|
||||||
|
return input
|
||||||
|
.parse::<i64>()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.map_err(|error| format!("Exact integer search value is invalid: {error}"));
|
||||||
|
}
|
||||||
|
Ok(input.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical spelling for a JSON number stored in a Tantivy text term.
|
||||||
|
pub fn canonical_search_number(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.parse::<rust_decimal::Decimal>()
|
||||||
|
.map(|value| value.normalize().to_string())
|
||||||
|
.unwrap_or_else(|_| input.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalizes a column name to the JSON-key form used at index time.
|
/// Normalizes a column name to the JSON-key form used at index time.
|
||||||
pub fn normalize_column_name(column: &str) -> String {
|
pub fn normalize_column_name(column: &str) -> String {
|
||||||
column.to_ascii_lowercase()
|
column.to_ascii_lowercase()
|
||||||
@@ -93,7 +129,11 @@ pub fn normalize_column_name(column: &str) -> String {
|
|||||||
pub fn create_search_schema() -> Schema {
|
pub fn create_search_schema() -> Schema {
|
||||||
let mut schema_builder = Schema::builder();
|
let mut schema_builder = Schema::builder();
|
||||||
|
|
||||||
schema_builder.add_u64_field(F_PG_ID, INDEXED | STORED);
|
schema_builder.add_u64_field(F_PG_ID, INDEXED | STORED | FAST);
|
||||||
|
schema_builder.add_u64_field(F_ROW_REVISION, STORED | FAST);
|
||||||
|
schema_builder.add_u64_field(F_IS_ARCHIVED, INDEXED | STORED | FAST);
|
||||||
|
schema_builder.add_u64_field(F_TABLE_DEFINITION_ID, STORED | FAST);
|
||||||
|
schema_builder.add_u64_field(F_VERSION_NUMBER, STORED | FAST);
|
||||||
schema_builder.add_text_field(F_TABLE_NAME, STRING | STORED);
|
schema_builder.add_text_field(F_TABLE_NAME, STRING | STORED);
|
||||||
schema_builder.add_text_field(F_ROW_KEY, STRING | STORED);
|
schema_builder.add_text_field(F_ROW_KEY, STRING | STORED);
|
||||||
schema_builder.add_text_field(F_ALL_TEXT, text_options(TOK_WORD));
|
schema_builder.add_text_field(F_ALL_TEXT, text_options(TOK_WORD));
|
||||||
@@ -197,6 +237,10 @@ pub fn json_path_term(field: Field, column: &str, text: &str) -> Term {
|
|||||||
/// Returns all required schema fields or fails loudly on mismatch.
|
/// Returns all required schema fields or fails loudly on mismatch.
|
||||||
pub struct SchemaFields {
|
pub struct SchemaFields {
|
||||||
pub pg_id: Field,
|
pub pg_id: Field,
|
||||||
|
pub row_revision: Field,
|
||||||
|
pub is_archived: Field,
|
||||||
|
pub table_definition_id: Field,
|
||||||
|
pub version_number: Field,
|
||||||
pub table_name: Field,
|
pub table_name: Field,
|
||||||
pub row_key: Field,
|
pub row_key: Field,
|
||||||
pub all_text: Field,
|
pub all_text: Field,
|
||||||
@@ -209,6 +253,10 @@ impl SchemaFields {
|
|||||||
pub fn from(schema: &Schema) -> tantivy::Result<Self> {
|
pub fn from(schema: &Schema) -> tantivy::Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
pg_id: get_field(schema, F_PG_ID)?,
|
pg_id: get_field(schema, F_PG_ID)?,
|
||||||
|
row_revision: get_field(schema, F_ROW_REVISION)?,
|
||||||
|
is_archived: get_field(schema, F_IS_ARCHIVED)?,
|
||||||
|
table_definition_id: get_field(schema, F_TABLE_DEFINITION_ID)?,
|
||||||
|
version_number: get_field(schema, F_VERSION_NUMBER)?,
|
||||||
table_name: get_field(schema, F_TABLE_NAME)?,
|
table_name: get_field(schema, F_TABLE_NAME)?,
|
||||||
row_key: get_field(schema, F_ROW_KEY)?,
|
row_key: get_field(schema, F_ROW_KEY)?,
|
||||||
all_text: get_field(schema, F_ALL_TEXT)?,
|
all_text: get_field(schema, F_ALL_TEXT)?,
|
||||||
@@ -221,7 +269,9 @@ impl SchemaFields {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{archived_search_row_key, parse_archived_search_row_key};
|
use super::{
|
||||||
|
archived_search_row_key, canonical_exact_search_value, parse_archived_search_row_key,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn archived_row_key_round_trips_stable_identity() {
|
fn archived_row_key_round_trips_stable_identity() {
|
||||||
@@ -230,6 +280,19 @@ mod tests {
|
|||||||
assert_eq!(parse_archived_search_row_key("customers:7"), None);
|
assert_eq!(parse_archived_search_row_key("customers:7"), None);
|
||||||
assert_eq!(parse_archived_search_row_key("__archive__:42:7"), None);
|
assert_eq!(parse_archived_search_row_key("__archive__:42:7"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_scalar_values_follow_catalog_field_types() {
|
||||||
|
assert_eq!(canonical_exact_search_value("001", "int").unwrap(), "1");
|
||||||
|
assert_eq!(canonical_exact_search_value("001", "bigint").unwrap(), "1");
|
||||||
|
assert_eq!(canonical_exact_search_value("001", "link(adresar)").unwrap(), "1");
|
||||||
|
assert_eq!(canonical_exact_search_value("10.50", "numeric").unwrap(), "10.5");
|
||||||
|
assert_eq!(canonical_exact_search_value("10.50", "money").unwrap(), "10.5");
|
||||||
|
assert_eq!(
|
||||||
|
canonical_exact_search_value("10.50", "decimal(12, 2)").unwrap(),
|
||||||
|
"10.5"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_field(schema: &Schema, name: &str) -> tantivy::Result<Field> {
|
fn get_field(schema: &Schema, name: &str) -> tantivy::Result<Field> {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ anyhow = { workspace = true }
|
|||||||
prost = { workspace = true }
|
prost = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
rust_decimal = { workspace = true }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tonic = { workspace = true }
|
tonic = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ use common::proto::komp_ac::search::{
|
|||||||
search_response::Hit,
|
search_response::Hit,
|
||||||
};
|
};
|
||||||
use common::search::{
|
use common::search::{
|
||||||
SchemaFields, parse_archived_search_row_key, register_tokenizers, search_index_path,
|
F_IS_ARCHIVED, F_PG_ID, F_ROW_REVISION, F_TABLE_DEFINITION_ID, F_VERSION_NUMBER,
|
||||||
search_row_key,
|
SchemaFields, canonical_exact_search_value, parse_archived_search_row_key,
|
||||||
|
register_tokenizers, search_index_path, search_row_key,
|
||||||
};
|
};
|
||||||
use common::system_column::{internal_column_names, is_internal_column, is_system_column};
|
use common::system_column::{internal_column_names, is_internal_column, is_system_column};
|
||||||
use query_builder::{
|
use query_builder::{
|
||||||
@@ -31,6 +32,7 @@ const HARD_RESULT_LIMIT: usize = 200;
|
|||||||
const DEFAULT_LIST_LIMIT: usize = 5;
|
const DEFAULT_LIST_LIMIT: usize = 5;
|
||||||
const SEARCH_SCORE_RELATIVE_FLOOR: f32 = 0.25;
|
const SEARCH_SCORE_RELATIVE_FLOOR: f32 = 0.25;
|
||||||
const SEARCH_SCORE_GROUP_WIDTH: f32 = 0.25;
|
const SEARCH_SCORE_GROUP_WIDTH: f32 = 0.25;
|
||||||
|
const SEARCH_VALIDATION_OVERFETCH_FACTOR: usize = 4;
|
||||||
|
|
||||||
pub struct SearcherService {
|
pub struct SearcherService {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
@@ -112,10 +114,6 @@ impl SearcherService {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let profile = profile_index(&self.profiles, &normalized.profile_name, &index_path)?;
|
let profile = profile_index(&self.profiles, &normalized.profile_name, &index_path)?;
|
||||||
profile
|
|
||||||
.reader
|
|
||||||
.reload()
|
|
||||||
.map_err(|error| Status::internal(format!("Search index reload failed: {error}")))?;
|
|
||||||
let mut hits = run_search(
|
let mut hits = run_search(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
&profile,
|
&profile,
|
||||||
@@ -127,6 +125,7 @@ impl SearcherService {
|
|||||||
normalized.offset,
|
normalized.offset,
|
||||||
normalized.order.as_ref(),
|
normalized.order.as_ref(),
|
||||||
normalized.version_scope,
|
normalized.version_scope,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -187,10 +186,6 @@ impl SearcherService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let profile = profile_index(&self.profiles, &normalized.profile_name, &index_path)?;
|
let profile = profile_index(&self.profiles, &normalized.profile_name, &index_path)?;
|
||||||
profile
|
|
||||||
.reader
|
|
||||||
.reload()
|
|
||||||
.map_err(|error| Status::internal(format!("Search index reload failed: {error}")))?;
|
|
||||||
let query = build_master_query(
|
let query = build_master_query(
|
||||||
&profile.index,
|
&profile.index,
|
||||||
&profile.fields,
|
&profile.fields,
|
||||||
@@ -219,12 +214,7 @@ async fn count_authoritative_matches(
|
|||||||
query: &dyn Query,
|
query: &dyn Query,
|
||||||
) -> Result<u64, Status> {
|
) -> Result<u64, Status> {
|
||||||
let searcher = profile.reader.searcher();
|
let searcher = profile.reader.searcher();
|
||||||
let documents = searcher
|
let documents = top_documents_for_count(&searcher, query)?;
|
||||||
.search(
|
|
||||||
query,
|
|
||||||
&TopDocs::with_limit(searcher.num_docs() as usize).order_by_score(),
|
|
||||||
)
|
|
||||||
.map_err(|error| Status::internal(format!("Search count failed: {error}")))?;
|
|
||||||
let best_score = documents
|
let best_score = documents
|
||||||
.first()
|
.first()
|
||||||
.map(|(score, _)| *score)
|
.map(|(score, _)| *score)
|
||||||
@@ -234,44 +224,92 @@ async fn count_authoritative_matches(
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let mut current_ids = Vec::new();
|
let mut current_rows = Vec::new();
|
||||||
let mut archives = Vec::new();
|
let mut archives = Vec::new();
|
||||||
for (score, address) in documents {
|
for (score, address) in documents {
|
||||||
if score < score_floor {
|
if score < score_floor {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let document: TantivyDocument = searcher
|
let segment = searcher.segment_reader(address.segment_ord);
|
||||||
.doc(address)
|
let fast_u64 = |field_name: &str| -> Result<i64, Status> {
|
||||||
.map_err(|error| Status::internal(format!("Search count document read failed: {error}")))?;
|
segment
|
||||||
let Some(row_key) = document
|
.fast_fields()
|
||||||
.get_first(profile.fields.row_key)
|
.u64(field_name)
|
||||||
.and_then(|value| value.as_str())
|
.map_err(|error| Status::internal(format!(
|
||||||
else {
|
"Search count fast field '{field_name}' failed: {error}"
|
||||||
continue;
|
)))?
|
||||||
|
.first(address.doc_id)
|
||||||
|
.and_then(|value| i64::try_from(value).ok())
|
||||||
|
.ok_or_else(|| Status::internal(format!(
|
||||||
|
"Search count document has no valid '{field_name}'"
|
||||||
|
)))
|
||||||
};
|
};
|
||||||
if let Some(archive) = parse_archived_search_row_key(row_key) {
|
let row_id = fast_u64(F_PG_ID)?;
|
||||||
archives.push(archive);
|
if fast_u64(F_IS_ARCHIVED)? != 0 {
|
||||||
} else if let Some(row_id) = document
|
archives.push((
|
||||||
.get_first(profile.fields.pg_id)
|
fast_u64(F_TABLE_DEFINITION_ID)?,
|
||||||
.and_then(|value| value.as_u64())
|
row_id,
|
||||||
.and_then(|value| i64::try_from(value).ok())
|
fast_u64(F_VERSION_NUMBER)?,
|
||||||
{
|
));
|
||||||
current_ids.push(row_id);
|
} else {
|
||||||
|
current_rows.push((row_id, fast_u64(F_ROW_REVISION)?));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
current_ids.sort_unstable();
|
current_rows.sort_unstable();
|
||||||
current_ids.dedup();
|
current_rows.dedup();
|
||||||
archives.sort_unstable();
|
archives.sort_unstable();
|
||||||
archives.dedup();
|
archives.dedup();
|
||||||
|
|
||||||
let current_count = if current_ids.is_empty() {
|
count_validated_candidates(
|
||||||
|
pool,
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
¤t_rows,
|
||||||
|
&archives,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn top_documents_for_count(
|
||||||
|
searcher: &tantivy::Searcher,
|
||||||
|
query: &dyn Query,
|
||||||
|
) -> Result<Vec<(f32, tantivy::DocAddress)>, Status> {
|
||||||
|
if searcher.num_docs() == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
// DocAddress is local to this exact Searcher snapshot. The caller must use
|
||||||
|
// the same searcher to load every address returned here.
|
||||||
|
searcher
|
||||||
|
.search(
|
||||||
|
query,
|
||||||
|
&TopDocs::with_limit(searcher.num_docs() as usize).order_by_score(),
|
||||||
|
)
|
||||||
|
.map_err(|error| Status::internal(format!("Search count failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_validated_candidates(
|
||||||
|
pool: &PgPool,
|
||||||
|
profile_name: &str,
|
||||||
|
table_name: &str,
|
||||||
|
current_rows: &[(i64, i64)],
|
||||||
|
archives: &[(i64, i64, i64)],
|
||||||
|
) -> Result<u64, Status> {
|
||||||
|
let current_count = if current_rows.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
let qualified_table = qualified_visible_table(pool, profile_name, table_name).await?;
|
let qualified_table = qualified_visible_table(pool, profile_name, table_name).await?;
|
||||||
|
let current_ids = current_rows.iter().map(|item| item.0).collect::<Vec<_>>();
|
||||||
|
let current_revisions = current_rows.iter().map(|item| item.1).collect::<Vec<_>>();
|
||||||
sqlx::query_scalar::<_, i64>(AssertSqlSafe(format!(
|
sqlx::query_scalar::<_, i64>(AssertSqlSafe(format!(
|
||||||
"SELECT COUNT(*) FROM {qualified_table} WHERE deleted = FALSE AND id = ANY($1)"
|
"SELECT COUNT(*) FROM {qualified_table} current_row \
|
||||||
|
JOIN UNNEST($1::BIGINT[], $2::BIGINT[]) \
|
||||||
|
AS candidate(id, row_revision) \
|
||||||
|
ON candidate.id = current_row.id \
|
||||||
|
AND candidate.row_revision = current_row.row_revision \
|
||||||
|
WHERE current_row.deleted = FALSE"
|
||||||
)))
|
)))
|
||||||
.bind(¤t_ids)
|
.bind(¤t_ids)
|
||||||
|
.bind(¤t_revisions)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| Status::internal(format!("Current search count validation failed: {error}")))?
|
.map_err(|error| Status::internal(format!("Current search count validation failed: {error}")))?
|
||||||
@@ -367,26 +405,7 @@ struct NormalizedColumnConstraint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_constraint_value(query: &str, field_type: &str) -> Result<String, Status> {
|
fn normalize_constraint_value(query: &str, field_type: &str) -> Result<String, Status> {
|
||||||
let normalized_type = field_type.trim().to_ascii_lowercase();
|
canonical_exact_search_value(query, field_type).map_err(Status::invalid_argument)
|
||||||
if normalized_type == "money" || normalized_type.starts_with("decimal(") {
|
|
||||||
return query
|
|
||||||
.parse::<rust_decimal::Decimal>()
|
|
||||||
.map(|value| value.normalize().to_string())
|
|
||||||
.map_err(|error| Status::invalid_argument(format!(
|
|
||||||
"Exact numeric search value is invalid: {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if matches!(normalized_type.as_str(), "integer" | "bigint")
|
|
||||||
|| normalized_type.starts_with("link(")
|
|
||||||
{
|
|
||||||
return query
|
|
||||||
.parse::<i64>()
|
|
||||||
.map(|value| value.to_string())
|
|
||||||
.map_err(|error| Status::invalid_argument(format!(
|
|
||||||
"Exact integer search value is invalid: {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(query.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn public_search_system_type(column: &str) -> Option<&'static str> {
|
fn public_search_system_type(column: &str) -> Option<&'static str> {
|
||||||
@@ -401,6 +420,7 @@ fn public_search_system_type(column: &str) -> Option<&'static str> {
|
|||||||
struct SearchCandidate {
|
struct SearchCandidate {
|
||||||
score: f32,
|
score: f32,
|
||||||
row_id: i64,
|
row_id: i64,
|
||||||
|
row_revision: i64,
|
||||||
table_name: String,
|
table_name: String,
|
||||||
row_key: String,
|
row_key: String,
|
||||||
}
|
}
|
||||||
@@ -1144,6 +1164,7 @@ async fn run_search(
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
order: Option<&NormalizedSearchOrder>,
|
order: Option<&NormalizedSearchOrder>,
|
||||||
version_scope: SearchVersionScope,
|
version_scope: SearchVersionScope,
|
||||||
|
exhaustive_validation: bool,
|
||||||
) -> Result<Vec<Hit>, Status> {
|
) -> Result<Vec<Hit>, Status> {
|
||||||
let master_query = build_master_query(
|
let master_query = build_master_query(
|
||||||
&profile.index,
|
&profile.index,
|
||||||
@@ -1155,10 +1176,13 @@ async fn run_search(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let searcher = profile.reader.searcher();
|
let searcher = profile.reader.searcher();
|
||||||
let window_limit = if order.is_some() {
|
let window_limit = if order.is_some() || exhaustive_validation {
|
||||||
searcher.num_docs() as usize
|
searcher.num_docs() as usize
|
||||||
} else {
|
} else {
|
||||||
offset.saturating_add(limit)
|
offset
|
||||||
|
.saturating_add(limit)
|
||||||
|
.saturating_mul(SEARCH_VALIDATION_OVERFETCH_FACTOR)
|
||||||
|
.min(searcher.num_docs() as usize)
|
||||||
};
|
};
|
||||||
if window_limit == 0 {
|
if window_limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -1181,15 +1205,7 @@ async fn run_search(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(score, _)| *score >= score_floor)
|
.filter(|(score, _)| *score >= score_floor)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let page_docs = if order.is_some() {
|
let page_docs = eligible_docs;
|
||||||
eligible_docs
|
|
||||||
} else {
|
|
||||||
eligible_docs
|
|
||||||
.into_iter()
|
|
||||||
.skip(offset)
|
|
||||||
.take(limit)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut candidates: Vec<SearchCandidate> = Vec::with_capacity(page_docs.len());
|
let mut candidates: Vec<SearchCandidate> = Vec::with_capacity(page_docs.len());
|
||||||
for (score, doc_address) in page_docs {
|
for (score, doc_address) in page_docs {
|
||||||
@@ -1214,9 +1230,17 @@ async fn run_search(
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let Some(row_revision) = doc
|
||||||
|
.get_first(profile.fields.row_revision)
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.and_then(|value| i64::try_from(value).ok())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
candidates.push(SearchCandidate {
|
candidates.push(SearchCandidate {
|
||||||
score,
|
score,
|
||||||
row_id: pg_id as i64,
|
row_id: pg_id as i64,
|
||||||
|
row_revision,
|
||||||
table_name: table_name.to_string(),
|
table_name: table_name.to_string(),
|
||||||
row_key: row_key.to_string(),
|
row_key: row_key.to_string(),
|
||||||
});
|
});
|
||||||
@@ -1240,20 +1264,20 @@ async fn run_search(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ids_by_table: HashMap<String, Vec<i64>> = HashMap::new();
|
let mut rows_by_table: HashMap<String, Vec<(i64, i64)>> = HashMap::new();
|
||||||
for candidate in &candidates {
|
for candidate in &candidates {
|
||||||
if parse_archived_search_row_key(&candidate.row_key).is_some() {
|
if parse_archived_search_row_key(&candidate.row_key).is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ids_by_table
|
rows_by_table
|
||||||
.entry(candidate.table_name.clone())
|
.entry(candidate.table_name.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(candidate.row_id);
|
.push((candidate.row_id, candidate.row_revision));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut content_map: HashMap<String, (String, Vec<String>, Vec<String>, i64, bool)> =
|
let mut content_map: HashMap<String, (String, Vec<String>, Vec<String>, i64, bool)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
for (table_name, pg_ids) in ids_by_table {
|
for (table_name, candidate_rows) in rows_by_table {
|
||||||
validate_identifier(&table_name, "table_name")?;
|
validate_identifier(&table_name, "table_name")?;
|
||||||
let physical_to_display =
|
let physical_to_display =
|
||||||
table_physical_to_display_map(pool, profile_name, &table_name).await?;
|
table_physical_to_display_map(pool, profile_name, &table_name).await?;
|
||||||
@@ -1261,6 +1285,8 @@ async fn run_search(
|
|||||||
table_internal_column_names(pool, profile_name, &table_name).await?;
|
table_internal_column_names(pool, profile_name, &table_name).await?;
|
||||||
let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?;
|
let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?;
|
||||||
let qualified_table = qualified_visible_table(pool, profile_name, &table_name).await?;
|
let qualified_table = qualified_visible_table(pool, profile_name, &table_name).await?;
|
||||||
|
let pg_ids = candidate_rows.iter().map(|item| item.0).collect::<Vec<_>>();
|
||||||
|
let revisions_by_id = candidate_rows.into_iter().collect::<HashMap<_, _>>();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE deleted = FALSE AND id = ANY($1)",
|
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE deleted = FALSE AND id = ANY($1)",
|
||||||
qualified_table
|
qualified_table
|
||||||
@@ -1274,6 +1300,12 @@ async fn run_search(
|
|||||||
for row in rows {
|
for row in rows {
|
||||||
let id: i64 = row.try_get("id").unwrap_or_default();
|
let id: i64 = row.try_get("id").unwrap_or_default();
|
||||||
let json_data: serde_json::Value = row.try_get("data").unwrap_or_default();
|
let json_data: serde_json::Value = row.try_get("data").unwrap_or_default();
|
||||||
|
let row_revision = json_data
|
||||||
|
.get("row_revision")
|
||||||
|
.and_then(|value| value.as_i64());
|
||||||
|
if revisions_by_id.get(&id).copied() != row_revision {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let version = json_data.get("version").and_then(|value| value.as_i64()).unwrap_or(0);
|
let version = json_data.get("version").and_then(|value| value.as_i64()).unwrap_or(0);
|
||||||
let json_data = remap_json_to_display_names(
|
let json_data = remap_json_to_display_names(
|
||||||
json_data,
|
json_data,
|
||||||
@@ -1371,7 +1403,7 @@ async fn run_search(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(candidates
|
let hits = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|candidate| {
|
.filter_map(|candidate| {
|
||||||
content_map
|
content_map
|
||||||
@@ -1388,7 +1420,32 @@ async fn run_search(
|
|||||||
archived: *archived,
|
archived: *archived,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect())
|
.collect::<Vec<_>>();
|
||||||
|
if order.is_some() {
|
||||||
|
Ok(hits)
|
||||||
|
} else {
|
||||||
|
let page = hits.into_iter().skip(offset).take(limit).collect::<Vec<_>>();
|
||||||
|
if !exhaustive_validation
|
||||||
|
&& page.len() < limit
|
||||||
|
&& window_limit < searcher.num_docs() as usize
|
||||||
|
{
|
||||||
|
return Box::pin(run_search(
|
||||||
|
pool,
|
||||||
|
profile,
|
||||||
|
profile_name,
|
||||||
|
table_filter,
|
||||||
|
free_query,
|
||||||
|
must,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
order,
|
||||||
|
version_scope,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(page)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_ordered_candidate_rows(
|
async fn fetch_ordered_candidate_rows(
|
||||||
@@ -1414,10 +1471,11 @@ async fn fetch_ordered_candidate_rows(
|
|||||||
SELECT positioned.id, to_jsonb(positioned) - 'picker_position' AS data, \
|
SELECT positioned.id, to_jsonb(positioned) - 'picker_position' AS data, \
|
||||||
picker_position, candidate_score \
|
picker_position, candidate_score \
|
||||||
FROM positioned \
|
FROM positioned \
|
||||||
JOIN UNNEST($1::BIGINT[], $2::REAL[], $3::INTEGER[]) \
|
JOIN UNNEST($1::BIGINT[], $2::BIGINT[], $3::REAL[], $4::INTEGER[]) \
|
||||||
AS candidate(candidate_id, candidate_score, candidate_group) \
|
AS candidate(candidate_id, candidate_revision, candidate_score, candidate_group) \
|
||||||
ON candidate_id = positioned.id \
|
ON candidate_id = positioned.id \
|
||||||
ORDER BY {} LIMIT $4 OFFSET $5",
|
AND candidate_revision = positioned.row_revision \
|
||||||
|
ORDER BY {} LIMIT $5 OFFSET $6",
|
||||||
qualified_table,
|
qualified_table,
|
||||||
ranked_order_clause(&resolved_order, order.direction),
|
ranked_order_clause(&resolved_order, order.direction),
|
||||||
);
|
);
|
||||||
@@ -1429,6 +1487,10 @@ async fn fetch_ordered_candidate_rows(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|candidate| candidate.score)
|
.map(|candidate| candidate.score)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let revisions = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| candidate.row_revision)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let best_score = scores
|
let best_score = scores
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
@@ -1440,6 +1502,7 @@ async fn fetch_ordered_candidate_rows(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let rows = sqlx::query(AssertSqlSafe(sql))
|
let rows = sqlx::query(AssertSqlSafe(sql))
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
|
.bind(&revisions)
|
||||||
.bind(&scores)
|
.bind(&scores)
|
||||||
.bind(&groups)
|
.bind(&groups)
|
||||||
.bind(limit as i64)
|
.bind(limit as i64)
|
||||||
@@ -1510,6 +1573,8 @@ impl Searcher for SearcherService {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use common::proto::komp_ac::search::{SearchOrder, SearchOrderDirection};
|
use common::proto::komp_ac::search::{SearchOrder, SearchOrderDirection};
|
||||||
|
use common::search::create_search_schema;
|
||||||
|
use tantivy::query::AllQuery;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_response_mapping_exposes_aliases_only() {
|
fn search_response_mapping_exposes_aliases_only() {
|
||||||
@@ -1524,6 +1589,24 @@ mod tests {
|
|||||||
assert_eq!(mapped, serde_json::json!({"customer": "Acme", "id": 4}));
|
assert_eq!(mapped, serde_json::json!({"customer": "Acme", "id": 4}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn count_collector_returns_zero_documents_for_an_empty_index() {
|
||||||
|
let schema = create_search_schema();
|
||||||
|
let index = Index::create_in_ram(schema.clone());
|
||||||
|
let profile = ProfileIndex {
|
||||||
|
reader: index.reader().expect("empty reader should open"),
|
||||||
|
fields: SchemaFields::from(&schema).expect("search schema should match"),
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
top_documents_for_count(&profile.reader.searcher(), &AllQuery)
|
||||||
|
.expect("empty count should not construct a zero-limit collector")
|
||||||
|
.len(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_response_mapping_fails_instead_of_leaking_a_real_name() {
|
fn search_response_mapping_fails_instead_of_leaking_a_real_name() {
|
||||||
let error = remap_json_to_display_names(
|
let error = remap_json_to_display_names(
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use common::search::{
|
use common::search::{
|
||||||
ARCHIVED_ROW_KEY_PREFIX, SchemaFields, json_path_term, normalize_column_name,
|
SchemaFields, json_path_term, normalize_column_name, normalize_exact, tokenize_ngram,
|
||||||
normalize_exact, tokenize_ngram, tokenize_word,
|
tokenize_word,
|
||||||
};
|
};
|
||||||
use common::proto::komp_ac::search::SearchVersionScope;
|
use common::proto::komp_ac::search::SearchVersionScope;
|
||||||
use tantivy::query::{
|
use tantivy::query::{
|
||||||
BooleanQuery, BoostQuery, EmptyQuery, FuzzyTermQuery, Occur, PhraseQuery, Query, QueryParser,
|
BooleanQuery, BoostQuery, EmptyQuery, FuzzyTermQuery, Occur, PhraseQuery, Query, QueryParser,
|
||||||
RegexQuery, TermQuery,
|
TermQuery,
|
||||||
};
|
};
|
||||||
use tantivy::schema::{IndexRecordOption, Term};
|
use tantivy::schema::{IndexRecordOption, Term};
|
||||||
use tantivy::Index;
|
use tantivy::Index;
|
||||||
@@ -62,14 +62,15 @@ pub fn build_master_query(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let archived_rows = RegexQuery::from_pattern(
|
let archived_rows = |archived| {
|
||||||
&format!("{ARCHIVED_ROW_KEY_PREFIX}.*"),
|
Box::new(TermQuery::new(
|
||||||
fields.row_key,
|
Term::from_field_u64(fields.is_archived, u64::from(archived)),
|
||||||
)
|
IndexRecordOption::Basic,
|
||||||
.map_err(|error| Status::internal(format!("Archived-row query build failed: {error}")))?;
|
)) as Box<dyn Query>
|
||||||
|
};
|
||||||
match version_scope {
|
match version_scope {
|
||||||
SearchVersionScope::Current => clauses.push((Occur::MustNot, Box::new(archived_rows))),
|
SearchVersionScope::Current => clauses.push((Occur::Must, archived_rows(false))),
|
||||||
SearchVersionScope::Archived => clauses.push((Occur::Must, Box::new(archived_rows))),
|
SearchVersionScope::Archived => clauses.push((Occur::Must, archived_rows(true))),
|
||||||
SearchVersionScope::All => {}
|
SearchVersionScope::All => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +329,17 @@ mod tests {
|
|||||||
for source in documents {
|
for source in documents {
|
||||||
let mut document = TantivyDocument::default();
|
let mut document = TantivyDocument::default();
|
||||||
document.add_u64(fields.pg_id, source.row_id);
|
document.add_u64(fields.pg_id, source.row_id);
|
||||||
|
document.add_u64(fields.row_revision, 1);
|
||||||
|
let archive = common::search::parse_archived_search_row_key(&source.row_key);
|
||||||
|
document.add_u64(fields.is_archived, u64::from(archive.is_some()));
|
||||||
|
document.add_u64(
|
||||||
|
fields.table_definition_id,
|
||||||
|
archive.map(|item| item.0 as u64).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
document.add_u64(
|
||||||
|
fields.version_number,
|
||||||
|
archive.map(|item| item.2 as u64).unwrap_or_default(),
|
||||||
|
);
|
||||||
document.add_text(fields.table_name, source.table_name);
|
document.add_text(fields.table_name, source.table_name);
|
||||||
document.add_text(fields.row_key, &source.row_key);
|
document.add_text(fields.row_key, &source.row_key);
|
||||||
let mut object = std::collections::BTreeMap::new();
|
let mut object = std::collections::BTreeMap::new();
|
||||||
|
|||||||
2
server
2
server
Submodule server updated: 3b7914b5e6...714104f760
Reference in New Issue
Block a user