use vantage_table::column::core::Column;
use vantage_table::column::flags::ColumnFlag;
use vantage_table::table::Table;
use vantage_table::traits::column_like::ColumnLike;
use vantage_types::Entity;
use crate::graphql::api::GraphqlApi;
use crate::graphql::condition::GraphqlCondition;
use crate::graphql::operation::GraphqlOperation;
use crate::graphql::types::AnyGraphqlType;
pub(crate) fn escape_like(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '\\' | '%' | '_') {
out.push('\\');
}
out.push(c);
}
out
}
pub(crate) fn contains_pattern(text: &str) -> String {
format!("%{}%", escape_like(text))
}
pub(crate) fn match_nothing() -> GraphqlCondition {
GraphqlCondition::Not(Box::new(GraphqlCondition::And(Vec::new())))
}
fn search_columns<E: Entity<AnyGraphqlType>>(table: &Table<GraphqlApi, E>) -> Vec<String> {
let searchable = |col: &Column<AnyGraphqlType>| col.flags().contains(&ColumnFlag::Searchable);
let any_flagged = table.columns().values().any(searchable);
let id = table.id_field().map(|c| c.name().to_string());
table
.columns()
.iter()
.filter(|(_, col)| {
if any_flagged {
searchable(col)
} else {
col.get_type() == std::any::type_name::<String>()
}
})
.filter(|(name, _)| !name.contains('.'))
.filter(|(name, _)| any_flagged || Some(name.as_str()) != id.as_deref())
.map(|(name, _)| name.clone())
.collect()
}
pub(crate) fn search_condition<E: Entity<AnyGraphqlType>>(
table: &Table<GraphqlApi, E>,
text: &str,
) -> GraphqlCondition {
let pattern = contains_pattern(text);
let mut conditions: Vec<GraphqlCondition> = search_columns(table)
.iter()
.map(|name| Column::<AnyGraphqlType>::new(name).ilike(pattern.clone()))
.collect();
match conditions.len() {
0 => match_nothing(),
1 => conditions.remove(0),
_ => GraphqlCondition::Or(conditions),
}
}
pub(crate) fn unescape_like(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
match c {
'\\' => out.extend(chars.next()),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vantage_types::EmptyEntity;
use crate::graphql::condition::{FilterDialect, GraphqlOp};
fn api() -> GraphqlApi {
GraphqlApi::new("https://api.test/graphql")
}
#[test]
fn metacharacters_are_escaped() {
assert_eq!(contains_pattern(r"50%_a\b"), r"%50\%\_a\\b%");
assert_eq!(unescape_like(r"50\%\_a\\b"), r"50%_a\b");
}
#[test]
fn typed_tables_search_their_string_columns_but_not_the_id() {
let table = Table::<GraphqlApi, EmptyEntity>::new("launches", api())
.with_id_column("id")
.with_column_of::<String>("mission_name")
.with_column_of::<i64>("launch_year")
.with_column_of::<String>("rocket.name");
assert_eq!(search_columns(&table), ["mission_name"]);
}
#[test]
fn searchable_flags_take_precedence() {
let mut table = Table::<GraphqlApi, EmptyEntity>::new("launches", api())
.with_id_column("id")
.with_column_of::<String>("mission_name");
table
.add_column(Column::<AnyGraphqlType>::new("details").with_flag(ColumnFlag::Searchable));
assert_eq!(search_columns(&table), ["details"]);
}
#[tokio::test]
async fn dotted_only_searchable_flags_match_nothing() {
let mut table = Table::<GraphqlApi, EmptyEntity>::new("launches", api())
.with_id_column("id")
.with_column_of::<String>("mission_name");
table.add_column(
Column::<AnyGraphqlType>::new("rocket.name").with_flag(ColumnFlag::Searchable),
);
assert!(search_columns(&table).is_empty());
assert_eq!(
search_condition(&table, "fal")
.render(FilterDialect::Hasura)
.await
.unwrap(),
json!({ "_not": {} })
);
}
#[tokio::test]
async fn no_searchable_column_matches_nothing() {
let table = Table::<GraphqlApi, EmptyEntity>::new("launches", api())
.with_id_column("id")
.with_column_of::<i64>("launch_year");
let cond = search_condition(&table, "falcon");
assert_eq!(
cond.render(FilterDialect::Hasura).await.unwrap(),
json!({ "_not": {} })
);
}
#[test]
fn a_single_column_search_is_one_ilike() {
let table = Table::<GraphqlApi, EmptyEntity>::new("launches", api())
.with_id_column("id")
.with_column_of::<String>("mission_name");
match search_condition(&table, "fal_con") {
GraphqlCondition::Field(fc) => {
assert_eq!(fc.op, GraphqlOp::ILike);
assert_eq!(fc.value, json!(r"%fal\_con%"));
}
other => panic!("expected one ilike, got {other:?}"),
}
}
}