mod conditions;
mod filter;
mod runtime;
use std::collections::HashMap;
use crate::schema::{Column, FilterKind, Relationship, Table};
use super::relationships;
use super::sql::live_condition;
pub use self::filter::render_filter;
pub use self::runtime::render_shared;
pub struct FilterRelationship<'a> {
pub relationship: &'a Relationship,
pub target: &'a Table,
pub join: Option<&'a Table>,
}
pub fn filter_relationships<'a>(
table: &'a Table,
tables: &'a [Table],
) -> Vec<FilterRelationship<'a>> {
if !SearchApi::for_table(table).any() {
return Vec::new();
}
table
.relationships
.iter()
.filter_map(|relationship| {
let resolved = relationships::resolve(table, relationship, tables);
let target = resolved.target;
let join = resolved.join.map(|join| join.table);
let nestable = SearchApi::for_table(target).any() && !target.relationships.is_empty();
let scoped = std::iter::once(target)
.chain(join)
.all(|related| related.tenant().is_none() || table.tenant().is_some());
(nestable && scoped).then_some(FilterRelationship {
relationship,
target,
join,
})
})
.collect()
}
pub fn check_filter_fields(table: &Table, tables: &[Table]) -> Result<(), String> {
let relationships = filter_relationships(table, tables);
if relationships.is_empty() {
return Ok(());
}
let mut fields: Vec<(String, String)> = Vec::new();
if live_condition(table, None).is_some() {
let name = super::conventions::INCLUDE_DELETED;
fields.push((name.to_owned(), format!("`{name}` switch")));
}
for column in filter_columns(table) {
if column.filter_kind().is_some() {
fields.push((
column.rust_name.clone(),
format!(
"`{}` field for column `{}`",
column.rust_name, column.sql_name
),
));
}
if column.nullable {
let name = column.null_check_field();
let described = format!("`{name}` field for column `{}`", column.sql_name);
fields.push((name, described));
}
}
for FilterRelationship { relationship, .. } in relationships {
if let Some((_, field)) = fields.iter().find(|(name, _)| *name == relationship.name) {
return Err(format!(
"the `{}` relationship of `{}` collides with the search filter's {field}",
relationship.name,
table.display_name()
));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchApi {
pub search: bool,
pub search_after: bool,
}
impl SearchApi {
pub fn for_table(table: &Table) -> Self {
Self {
search: !sortable(table).is_empty(),
search_after: !table.key_columns().is_empty(),
}
}
pub fn any(self) -> bool {
self.search || self.search_after
}
}
impl FilterKind {
fn type_name(self) -> &'static str {
match self {
FilterKind::Ordered => "Ordered",
FilterKind::Text => "Text",
FilterKind::Equality => "Equality",
}
}
fn condition_type(self, column: &Column) -> String {
match self {
FilterKind::Text => self.type_name().to_owned(),
_ => format!("{}<{}>", self.type_name(), column.value_type()),
}
}
}
fn push_call(output: &mut String, indent: usize, callee: &str, args: &[String]) {
push_call_ending(output, indent, callee, args, ";");
}
fn push_call_ending(output: &mut String, indent: usize, callee: &str, args: &[String], end: &str) {
let pad = " ".repeat(indent);
let joined = args.join(", ");
let one_line = format!("{pad}{callee}({joined}){end}");
if joined.len() <= super::FN_CALL_WIDTH && one_line.len() <= super::MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
return;
}
output.push_str(&format!("{pad}{callee}(\n"));
for arg in args {
output.push_str(&format!("{pad} {arg},\n"));
}
output.push_str(&format!("{pad}){end}\n"));
}
fn push_block(output: &mut String, head: &str, items: &[String], indent: usize) {
let pad = " ".repeat(indent);
if items.is_empty() {
output.push_str(&format!("{pad}{head} {{}}\n"));
return;
}
output.push_str(&format!("{pad}{head} {{\n"));
for item in items {
output.push_str(&format!("{pad} {item}\n"));
}
output.push_str(&format!("{pad}}}\n"));
}
fn filter_columns(table: &Table) -> impl Iterator<Item = &Column> {
table
.columns
.iter()
.filter(|column| !table.is_tenant_column(column))
}
pub fn name_clash(table: &Table) -> Option<String> {
let mut fields: HashMap<String, &str> = filter_columns(table)
.map(|column| (column.rust_name.clone(), column.sql_name.as_str()))
.collect();
for column in filter_columns(table).filter(|column| column.nullable) {
let field = column.null_check_field();
if let Some(other) = fields.insert(field.clone(), &column.sql_name) {
return Some(format!(
"column `{}` needs the null-check filter field `{field}`, which column `{other}` already uses",
column.sql_name
));
}
}
let mut variants: HashMap<String, &str> = HashMap::new();
for column in sortable(table) {
let variant = column.sort_variant();
if let Some(other) = variants.insert(variant.clone(), &column.sql_name) {
return Some(format!(
"columns `{other}` and `{}` both map to the sort key variant `{variant}`",
column.sql_name
));
}
}
None
}
pub fn sortable(table: &Table) -> Vec<&Column> {
filter_columns(table)
.filter(|column| column.filter_kind().is_some())
.collect()
}
const QUERY_PARAM: &str = "query: &mut sqlx::QueryBuilder<sqlx::Postgres>";
#[cfg(test)]
mod tests {
use super::super::test_support::{column, table};
use super::*;
#[test]
fn search_code_names_query_builder_without_a_lifetime() {
let filter = render_filter(&table(vec![column("id", "i64")], &["id"]), &[], false)
.expect("an i64 column is filterable");
for rendered in [render_shared(false), filter] {
assert!(rendered.contains("QueryBuilder<"), "{rendered}");
assert!(!rendered.contains("QueryBuilder<'"), "{rendered}");
}
}
#[test]
fn serde_derives_filters_and_sort_keys_on_request() {
let users = table(vec![column("id", "i64")], &["id"]);
assert!(!render_filter(&users, &[], false).unwrap().contains("serde"));
let rendered = render_filter(&users, &[], true).unwrap();
assert!(rendered.contains(
"#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]\n#[serde(default)]\npub struct UserFilter {"
), "{rendered}");
assert!(rendered.contains(
"#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum UserSortKey {"
), "{rendered}");
}
#[test]
fn serde_derives_shared_conditions_and_sorting_but_not_pages() {
assert!(!render_shared(false).contains("serde"));
let rendered = render_shared(true);
for item in [
"pub enum Direction {",
"pub enum Ordered<T> {",
"pub enum Equality<T> {",
"pub enum Text {",
] {
assert!(
rendered.contains(&format!(
"serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\n{item}"
)),
"{item}:\n{rendered}"
);
}
assert!(
rendered.contains("serde::Deserialize)]\npub struct Sort<K> {"),
"{rendered}"
);
for item in ["pub struct Page {", "pub struct Limit(i64);"] {
assert!(
rendered.contains(&format!("PartialEq, Eq)]\n{item}")),
"{item}:\n{rendered}"
);
}
assert_eq!(rendered.matches("serde::Serialize").count(), 5);
}
}