picker order by
This commit is contained in:
30
Cargo.lock
generated
30
Cargo.lock
generated
@@ -156,7 +156,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -952,7 +952,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1045,7 +1045,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
"prost-build",
|
"prost-build",
|
||||||
@@ -2257,7 +2257,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2400,7 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4242,7 +4242,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5719,7 +5719,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5803,7 +5803,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "search"
|
name = "search"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -5953,7 +5953,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"analytics-graphs",
|
"analytics-graphs",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6887,7 +6887,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7451,7 +7451,7 @@ checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tui-canvas"
|
name = "tui-canvas"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -7470,7 +7470,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tui-canvas-validation-core 0.8.24",
|
"tui-canvas-validation-core 0.8.25",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7487,7 +7487,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tui-canvas-validation-core"
|
name = "tui-canvas-validation-core"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7497,7 +7497,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tui-pages"
|
name = "tui-pages"
|
||||||
version = "0.8.24"
|
version = "0.8.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"nucleo",
|
"nucleo",
|
||||||
@@ -8140,7 +8140,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
2
client
2
client
Submodule client updated: 0d20818048...643795ac6f
@@ -25,6 +25,18 @@ message SearchRequest {
|
|||||||
repeated ColumnConstraint must = 4;
|
repeated ColumnConstraint must = 4;
|
||||||
optional uint32 limit = 5;
|
optional uint32 limit = 5;
|
||||||
optional uint32 offset = 6;
|
optional uint32 offset = 6;
|
||||||
|
optional SearchOrder order = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchOrder {
|
||||||
|
string column = 1;
|
||||||
|
SearchOrderDirection direction = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SearchOrderDirection {
|
||||||
|
SEARCH_ORDER_DIRECTION_UNSPECIFIED = 0;
|
||||||
|
SEARCH_ORDER_DIRECTION_ASC = 1;
|
||||||
|
SEARCH_ORDER_DIRECTION_DESC = 2;
|
||||||
}
|
}
|
||||||
message SearchResponse {
|
message SearchResponse {
|
||||||
message Hit {
|
message Hit {
|
||||||
@@ -35,6 +47,7 @@ message SearchResponse {
|
|||||||
// Configured human-readable value for this row.
|
// Configured human-readable value for this row.
|
||||||
string row_display_value = 5;
|
string row_display_value = 5;
|
||||||
string row_display_column = 6;
|
string row_display_column = 6;
|
||||||
|
optional uint64 position = 7;
|
||||||
}
|
}
|
||||||
repeated Hit hits = 1;
|
repeated Hit hits = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -22,6 +22,15 @@ pub struct SearchRequest {
|
|||||||
pub limit: ::core::option::Option<u32>,
|
pub limit: ::core::option::Option<u32>,
|
||||||
#[prost(uint32, optional, tag = "6")]
|
#[prost(uint32, optional, tag = "6")]
|
||||||
pub offset: ::core::option::Option<u32>,
|
pub offset: ::core::option::Option<u32>,
|
||||||
|
#[prost(message, optional, tag = "7")]
|
||||||
|
pub order: ::core::option::Option<SearchOrder>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||||
|
pub struct SearchOrder {
|
||||||
|
#[prost(string, tag = "1")]
|
||||||
|
pub column: ::prost::alloc::string::String,
|
||||||
|
#[prost(enumeration = "SearchOrderDirection", tag = "2")]
|
||||||
|
pub direction: i32,
|
||||||
}
|
}
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct SearchResponse {
|
pub struct SearchResponse {
|
||||||
@@ -46,6 +55,8 @@ pub mod search_response {
|
|||||||
pub row_display_value: ::prost::alloc::string::String,
|
pub row_display_value: ::prost::alloc::string::String,
|
||||||
#[prost(string, tag = "6")]
|
#[prost(string, tag = "6")]
|
||||||
pub row_display_column: ::prost::alloc::string::String,
|
pub row_display_column: ::prost::alloc::string::String,
|
||||||
|
#[prost(uint64, optional, tag = "7")]
|
||||||
|
pub position: ::core::option::Option<u64>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||||
@@ -77,6 +88,35 @@ impl MatchMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||||
|
#[repr(i32)]
|
||||||
|
pub enum SearchOrderDirection {
|
||||||
|
Unspecified = 0,
|
||||||
|
Asc = 1,
|
||||||
|
Desc = 2,
|
||||||
|
}
|
||||||
|
impl SearchOrderDirection {
|
||||||
|
/// String value of the enum field names used in the ProtoBuf definition.
|
||||||
|
///
|
||||||
|
/// The values are not transformed in any way and thus are considered stable
|
||||||
|
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
|
||||||
|
pub fn as_str_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unspecified => "SEARCH_ORDER_DIRECTION_UNSPECIFIED",
|
||||||
|
Self::Asc => "SEARCH_ORDER_DIRECTION_ASC",
|
||||||
|
Self::Desc => "SEARCH_ORDER_DIRECTION_DESC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Creates an enum from field names used in the ProtoBuf definition.
|
||||||
|
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
|
||||||
|
match value {
|
||||||
|
"SEARCH_ORDER_DIRECTION_UNSPECIFIED" => Some(Self::Unspecified),
|
||||||
|
"SEARCH_ORDER_DIRECTION_ASC" => Some(Self::Asc),
|
||||||
|
"SEARCH_ORDER_DIRECTION_DESC" => Some(Self::Desc),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Generated client implementations.
|
/// Generated client implementations.
|
||||||
pub mod searcher_client {
|
pub mod searcher_client {
|
||||||
#![allow(
|
#![allow(
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use common::proto::komp_ac::search::searcher_server::Searcher;
|
use common::proto::komp_ac::search::searcher_server::Searcher;
|
||||||
pub use common::proto::komp_ac::search::searcher_server::SearcherServer;
|
pub use common::proto::komp_ac::search::searcher_server::SearcherServer;
|
||||||
use common::proto::komp_ac::search::{SearchRequest, SearchResponse, search_response::Hit};
|
use common::proto::komp_ac::search::{
|
||||||
|
SearchOrderDirection, SearchRequest, SearchResponse, search_response::Hit,
|
||||||
|
};
|
||||||
use common::search::{SchemaFields, register_tokenizers, search_index_path};
|
use common::search::{SchemaFields, register_tokenizers, search_index_path};
|
||||||
use query_builder::{
|
use query_builder::{
|
||||||
ConstraintMode, SearchConstraint, SearchConstraintTarget, build_master_query,
|
ConstraintMode, SearchConstraint, SearchConstraintTarget, build_master_query,
|
||||||
@@ -67,10 +69,11 @@ impl SearcherService {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let hits = fetch_latest_rows(
|
let hits = fetch_ordered_rows(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
&normalized.profile_name,
|
&normalized.profile_name,
|
||||||
table_name,
|
table_name,
|
||||||
|
normalized.order.as_ref(),
|
||||||
normalized.limit.unwrap_or(DEFAULT_LIST_LIMIT),
|
normalized.limit.unwrap_or(DEFAULT_LIST_LIMIT),
|
||||||
normalized.offset,
|
normalized.offset,
|
||||||
)
|
)
|
||||||
@@ -104,15 +107,18 @@ impl SearcherService {
|
|||||||
&resolved_must,
|
&resolved_must,
|
||||||
normalized.limit.unwrap_or(DEFAULT_RESULT_LIMIT),
|
normalized.limit.unwrap_or(DEFAULT_RESULT_LIMIT),
|
||||||
normalized.offset,
|
normalized.offset,
|
||||||
|
normalized.order.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if normalized.order.is_none() {
|
||||||
hits.sort_by(|left, right| right.score.total_cmp(&left.score));
|
hits.sort_by(|left, right| right.score.total_cmp(&left.score));
|
||||||
if let Some(limit) = normalized.limit {
|
if let Some(limit) = normalized.limit {
|
||||||
if hits.len() > limit {
|
if hits.len() > limit {
|
||||||
hits.truncate(limit);
|
hits.truncate(limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"search: profile={} table={:?} free='{}' constraints={} hits={}",
|
"search: profile={} table={:?} free='{}' constraints={} hits={}",
|
||||||
@@ -167,6 +173,13 @@ struct NormalizedSearchRequest {
|
|||||||
must: Vec<NormalizedColumnConstraint>,
|
must: Vec<NormalizedColumnConstraint>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
|
order: Option<NormalizedSearchOrder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct NormalizedSearchOrder {
|
||||||
|
column: String,
|
||||||
|
direction: SearchOrderDirection,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -315,6 +328,34 @@ fn normalize_request(req: SearchRequest) -> Result<NormalizedSearchRequest, Stat
|
|||||||
.limit
|
.limit
|
||||||
.map(|value| (value as usize).min(HARD_RESULT_LIMIT));
|
.map(|value| (value as usize).min(HARD_RESULT_LIMIT));
|
||||||
let offset = req.offset.unwrap_or_default() as usize;
|
let offset = req.offset.unwrap_or_default() as usize;
|
||||||
|
let order = req
|
||||||
|
.order
|
||||||
|
.map(|order| {
|
||||||
|
let column = order.column.trim();
|
||||||
|
if column.is_empty() {
|
||||||
|
return Err(Status::invalid_argument("order.column must not be empty"));
|
||||||
|
}
|
||||||
|
validate_search_column(column)?;
|
||||||
|
let direction = SearchOrderDirection::try_from(order.direction).map_err(|_| {
|
||||||
|
Status::invalid_argument("order.direction must be ASC or DESC")
|
||||||
|
})?;
|
||||||
|
if direction == SearchOrderDirection::Unspecified {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"order.direction must be ASC or DESC",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(NormalizedSearchOrder {
|
||||||
|
column: column.to_string(),
|
||||||
|
direction,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
if order.is_some() && table_name.is_none() {
|
||||||
|
return Err(Status::invalid_argument(
|
||||||
|
"table_name is required when order is specified",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(NormalizedSearchRequest {
|
Ok(NormalizedSearchRequest {
|
||||||
profile_name: profile_name.to_string(),
|
profile_name: profile_name.to_string(),
|
||||||
@@ -323,6 +364,7 @@ fn normalize_request(req: SearchRequest) -> Result<NormalizedSearchRequest, Stat
|
|||||||
must,
|
must,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
|
order,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,18 +549,107 @@ fn row_display_value(value: &serde_json::Value, column: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_latest_rows(
|
enum ResolvedOrderColumn {
|
||||||
|
Position,
|
||||||
|
Column(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_direction_sql(direction: SearchOrderDirection) -> &'static str {
|
||||||
|
match direction {
|
||||||
|
SearchOrderDirection::Asc => "ASC",
|
||||||
|
SearchOrderDirection::Desc => "DESC",
|
||||||
|
SearchOrderDirection::Unspecified => unreachable!("order direction is normalized"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_order_column(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
profile_name: &str,
|
profile_name: &str,
|
||||||
table_name: &str,
|
table_name: &str,
|
||||||
|
requested_column: &str,
|
||||||
|
) -> Result<ResolvedOrderColumn, Status> {
|
||||||
|
if requested_column.eq_ignore_ascii_case("position") {
|
||||||
|
return Ok(ResolvedOrderColumn::Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_column = if requested_column.eq_ignore_ascii_case("row_display_column") {
|
||||||
|
table_row_display_column(pool, profile_name, table_name).await?
|
||||||
|
} else {
|
||||||
|
requested_column.to_string()
|
||||||
|
};
|
||||||
|
let physical_to_display =
|
||||||
|
table_physical_to_display_map(pool, profile_name, table_name).await?;
|
||||||
|
let physical_column = physical_to_display
|
||||||
|
.iter()
|
||||||
|
.find(|(physical, display)| {
|
||||||
|
physical.eq_ignore_ascii_case(&requested_column)
|
||||||
|
|| display.eq_ignore_ascii_case(&requested_column)
|
||||||
|
})
|
||||||
|
.map(|(physical, _)| physical.clone())
|
||||||
|
.unwrap_or_else(|| requested_column.clone());
|
||||||
|
|
||||||
|
let physical_column = sqlx::query_scalar::<_, String>(
|
||||||
|
r#"
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1 AND table_name = $2 AND LOWER(column_name) = LOWER($3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(profile_name)
|
||||||
|
.bind(table_name)
|
||||||
|
.bind(&physical_column)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("Order column lookup failed: {}", e)))?;
|
||||||
|
let Some(physical_column) = physical_column else {
|
||||||
|
return Err(Status::invalid_argument(format!(
|
||||||
|
"Order column '{}' was not found in table '{}.{}'",
|
||||||
|
requested_column, profile_name, table_name
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedOrderColumn::Column(physical_column))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_clause(column: &ResolvedOrderColumn, direction: SearchOrderDirection) -> String {
|
||||||
|
let direction = order_direction_sql(direction);
|
||||||
|
match column {
|
||||||
|
ResolvedOrderColumn::Position => {
|
||||||
|
format!("picker_position {}", direction)
|
||||||
|
}
|
||||||
|
ResolvedOrderColumn::Column(column) => {
|
||||||
|
let quoted_column = format!("\"{}\"", column.replace('"', "\"\""));
|
||||||
|
format!("{} {} NULLS LAST, id ASC", quoted_column, direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_ordered_rows(
|
||||||
|
pool: &PgPool,
|
||||||
|
profile_name: &str,
|
||||||
|
table_name: &str,
|
||||||
|
order: Option<&NormalizedSearchOrder>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Result<Vec<Hit>, Status> {
|
) -> Result<Vec<Hit>, Status> {
|
||||||
let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?;
|
let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?;
|
||||||
let display_column = table_row_display_column(pool, profile_name, table_name).await?;
|
let display_column = table_row_display_column(pool, profile_name, table_name).await?;
|
||||||
|
let (resolved_order, direction) = match order {
|
||||||
|
Some(order) => (
|
||||||
|
resolve_order_column(pool, profile_name, table_name, &order.column).await?,
|
||||||
|
order.direction,
|
||||||
|
),
|
||||||
|
None => (ResolvedOrderColumn::Position, SearchOrderDirection::Desc),
|
||||||
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE deleted = FALSE ORDER BY id DESC LIMIT $1 OFFSET $2",
|
"WITH positioned AS (\
|
||||||
qualify_profile_table(profile_name, table_name)
|
SELECT t.*, ROW_NUMBER() OVER (ORDER BY id ASC) AS picker_position \
|
||||||
|
FROM {} t WHERE deleted = FALSE\
|
||||||
|
) \
|
||||||
|
SELECT id, to_jsonb(positioned) - 'picker_position' AS data, picker_position \
|
||||||
|
FROM positioned ORDER BY {} LIMIT $1 OFFSET $2",
|
||||||
|
qualify_profile_table(profile_name, table_name),
|
||||||
|
order_clause(&resolved_order, direction),
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows = sqlx::query(AssertSqlSafe(sql))
|
let rows = sqlx::query(AssertSqlSafe(sql))
|
||||||
@@ -533,6 +664,7 @@ async fn fetch_latest_rows(
|
|||||||
.map(|row| {
|
.map(|row| {
|
||||||
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 position: i64 = row.try_get("picker_position").unwrap_or_default();
|
||||||
let json_data = remap_json_to_display_names(json_data, &physical_to_display);
|
let json_data = remap_json_to_display_names(json_data, &physical_to_display);
|
||||||
let row_display_value = row_display_value(&json_data, &display_column);
|
let row_display_value = row_display_value(&json_data, &display_column);
|
||||||
Hit {
|
Hit {
|
||||||
@@ -542,6 +674,7 @@ async fn fetch_latest_rows(
|
|||||||
table_name: table_name.to_string(),
|
table_name: table_name.to_string(),
|
||||||
row_display_value,
|
row_display_value,
|
||||||
row_display_column: display_column.clone(),
|
row_display_column: display_column.clone(),
|
||||||
|
position: u64::try_from(position).ok(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -556,6 +689,7 @@ async fn run_search(
|
|||||||
must: &[SearchConstraint],
|
must: &[SearchConstraint],
|
||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
|
order: Option<&NormalizedSearchOrder>,
|
||||||
) -> Result<Vec<Hit>, Status> {
|
) -> Result<Vec<Hit>, Status> {
|
||||||
let master_query = build_master_query(
|
let master_query = build_master_query(
|
||||||
&profile.index,
|
&profile.index,
|
||||||
@@ -566,7 +700,14 @@ async fn run_search(
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let searcher = profile.reader.searcher();
|
let searcher = profile.reader.searcher();
|
||||||
let window_limit = offset.saturating_add(limit);
|
let window_limit = if order.is_some() {
|
||||||
|
searcher.num_docs() as usize
|
||||||
|
} else {
|
||||||
|
offset.saturating_add(limit)
|
||||||
|
};
|
||||||
|
if window_limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
let top_docs = searcher
|
let top_docs = searcher
|
||||||
.search(&*master_query, &TopDocs::with_limit(window_limit).order_by_score())
|
.search(&*master_query, &TopDocs::with_limit(window_limit).order_by_score())
|
||||||
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
|
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
|
||||||
@@ -581,12 +722,19 @@ async fn run_search(
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let page_docs = top_docs
|
let eligible_docs = top_docs
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(score, _)| *score >= score_floor)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let page_docs = if order.is_some() {
|
||||||
|
eligible_docs
|
||||||
|
} else {
|
||||||
|
eligible_docs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.skip(offset)
|
.skip(offset)
|
||||||
.filter(|(score, _)| *score >= score_floor)
|
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
let mut candidates: Vec<(f32, i64, String)> = Vec::with_capacity(page_docs.len());
|
let mut candidates: Vec<(f32, i64, String)> = Vec::with_capacity(page_docs.len());
|
||||||
for (score, doc_address) in page_docs {
|
for (score, doc_address) in page_docs {
|
||||||
@@ -612,6 +760,20 @@ async fn run_search(
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(order) = order {
|
||||||
|
let table_name = table_filter.expect("ordered searches require a normalized table filter");
|
||||||
|
return fetch_ordered_candidate_rows(
|
||||||
|
pool,
|
||||||
|
profile_name,
|
||||||
|
table_name,
|
||||||
|
&candidates,
|
||||||
|
order,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let mut ids_by_table: HashMap<String, Vec<i64>> = HashMap::new();
|
let mut ids_by_table: HashMap<String, Vec<i64>> = HashMap::new();
|
||||||
for (_, pg_id, table_name) in &candidates {
|
for (_, pg_id, table_name) in &candidates {
|
||||||
ids_by_table
|
ids_by_table
|
||||||
@@ -661,11 +823,72 @@ async fn run_search(
|
|||||||
table_name,
|
table_name,
|
||||||
row_display_value: row_display_value.clone(),
|
row_display_value: row_display_value.clone(),
|
||||||
row_display_column: row_display_column.clone(),
|
row_display_column: row_display_column.clone(),
|
||||||
|
position: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_ordered_candidate_rows(
|
||||||
|
pool: &PgPool,
|
||||||
|
profile_name: &str,
|
||||||
|
table_name: &str,
|
||||||
|
candidates: &[(f32, i64, String)],
|
||||||
|
order: &NormalizedSearchOrder,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<Vec<Hit>, Status> {
|
||||||
|
let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?;
|
||||||
|
let display_column = table_row_display_column(pool, profile_name, table_name).await?;
|
||||||
|
let resolved_order =
|
||||||
|
resolve_order_column(pool, profile_name, table_name, &order.column).await?;
|
||||||
|
let sql = format!(
|
||||||
|
"WITH positioned AS (\
|
||||||
|
SELECT t.*, ROW_NUMBER() OVER (ORDER BY id ASC) AS picker_position \
|
||||||
|
FROM {} t WHERE deleted = FALSE\
|
||||||
|
) \
|
||||||
|
SELECT id, to_jsonb(positioned) - 'picker_position' AS data, picker_position \
|
||||||
|
FROM positioned WHERE id = ANY($1) ORDER BY {} LIMIT $2 OFFSET $3",
|
||||||
|
qualify_profile_table(profile_name, table_name),
|
||||||
|
order_clause(&resolved_order, order.direction),
|
||||||
|
);
|
||||||
|
let ids = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|(_, id, _)| *id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let scores = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|(score, id, _)| (*id, *score))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let rows = sqlx::query(AssertSqlSafe(sql))
|
||||||
|
.bind(&ids)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("Ordered search query failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let id: i64 = row.try_get("id").unwrap_or_default();
|
||||||
|
let json_data: serde_json::Value = row.try_get("data").unwrap_or_default();
|
||||||
|
let position: i64 = row.try_get("picker_position").unwrap_or_default();
|
||||||
|
let json_data = remap_json_to_display_names(json_data, &physical_to_display);
|
||||||
|
let display_value = row_display_value(&json_data, &display_column);
|
||||||
|
Hit {
|
||||||
|
id,
|
||||||
|
score: scores.get(&id).copied().unwrap_or_default(),
|
||||||
|
content_json: json_data.to_string(),
|
||||||
|
table_name: table_name.to_string(),
|
||||||
|
row_display_value: display_value,
|
||||||
|
row_display_column: display_column.clone(),
|
||||||
|
position: u64::try_from(position).ok(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
impl Searcher for SearcherService {
|
impl Searcher for SearcherService {
|
||||||
async fn search(
|
async fn search(
|
||||||
@@ -675,3 +898,61 @@ impl Searcher for SearcherService {
|
|||||||
self.run_rpc(request).await
|
self.run_rpc(request).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::proto::komp_ac::search::{SearchOrder, SearchOrderDirection};
|
||||||
|
|
||||||
|
fn request(order: Option<SearchOrder>, table_name: Option<&str>) -> SearchRequest {
|
||||||
|
SearchRequest {
|
||||||
|
profile_name: "finance".to_string(),
|
||||||
|
table_name: table_name.map(str::to_string),
|
||||||
|
free_query: String::new(),
|
||||||
|
must: Vec::new(),
|
||||||
|
limit: Some(20),
|
||||||
|
offset: Some(40),
|
||||||
|
order,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_typed_column_order() {
|
||||||
|
let normalized = normalize_request(request(
|
||||||
|
Some(SearchOrder {
|
||||||
|
column: "created_at".to_string(),
|
||||||
|
direction: SearchOrderDirection::Desc as i32,
|
||||||
|
}),
|
||||||
|
Some("invoice"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let order = normalized.order.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(order.column, "created_at");
|
||||||
|
assert_eq!(order.direction, SearchOrderDirection::Desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordered_request_requires_a_table() {
|
||||||
|
let result = normalize_request(request(
|
||||||
|
Some(SearchOrder {
|
||||||
|
column: "position".to_string(),
|
||||||
|
direction: SearchOrderDirection::Desc as i32,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn column_order_has_stable_id_tie_breaker() {
|
||||||
|
assert_eq!(
|
||||||
|
order_clause(
|
||||||
|
&ResolvedOrderColumn::Column("1".to_string()),
|
||||||
|
SearchOrderDirection::Asc,
|
||||||
|
),
|
||||||
|
"\"1\" ASC NULLS LAST, id ASC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user