use crate::{
blocks::{
BuildableFilter,
BuildableJoin,
BuildablePage,
BuildableSort,
Expression,
Pagination,
SortOrder,
},
web::{
GenericWebExpression,
WebDeleteFilter,
WebExpression,
WebPagination,
WebQueryError,
WebReadFilter,
WebUpdateFilter,
},
DeleteQueryBuilder,
QueryBuilder,
ReadQueryBuilder,
Result,
UpdateQueryBuilder,
};
use sqlxo_traits::{
Bind,
FullTextSearchConfigBuilder,
FullTextSearchJoinConfig,
FullTextSearchable,
JoinKind,
JoinNavigationModel,
JoinPath,
QueryContext,
WebQueryModel,
};
fn map_expr<C, D>(e: &WebExpression<D>) -> Expression<C::Query>
where
C: QueryContext,
D: WebQueryModel + Bind<C>,
{
match e {
GenericWebExpression::And { and } => {
Expression::And(and.iter().map(map_expr::<C, D>).collect())
}
GenericWebExpression::Or { or } => {
Expression::Or(or.iter().map(map_expr::<C, D>).collect())
}
GenericWebExpression::Leaf(l) => {
Expression::Leaf(<D as Bind<C>>::map_leaf(l))
}
}
}
fn default_search_relation_paths<C>() -> Vec<Vec<String>>
where
C: QueryContext,
C::Model: JoinNavigationModel,
{
let mut unique = Vec::new();
for path in C::Model::default_join_paths(true).into_vec() {
let segments = path
.segments()
.iter()
.map(|segment| segment.descriptor.identifier.to_string())
.collect::<Vec<_>>();
if segments.is_empty() {
continue;
}
if !unique.iter().any(|existing| existing == &segments) {
unique.push(segments);
}
}
unique
}
fn segment_path_is_prefix(prefix: &[String], candidate: &[String]) -> bool {
prefix.len() <= candidate.len() &&
prefix
.iter()
.zip(candidate.iter())
.all(|(left, right)| left == right)
}
fn canonicalize_search_relation_paths_with<F>(
paths: Vec<Vec<String>>,
is_searchable: F,
) -> Vec<Vec<String>>
where
F: Fn(&[String]) -> bool,
{
let mut searchable: Vec<Vec<String>> = Vec::new();
for segments in paths {
if segments.is_empty() || !is_searchable(&segments) {
continue;
}
if !searchable.iter().any(|existing| existing == &segments) {
searchable.push(segments);
}
}
let mut roots: Vec<String> = Vec::new();
for segments in &searchable {
let root = &segments[0];
if !roots.iter().any(|existing| existing == root) {
roots.push(root.clone());
}
}
let mut canonical: Vec<Vec<String>> = Vec::new();
for root in roots {
let root_path = vec![root.clone()];
if is_searchable(&root_path) &&
!canonical.iter().any(|existing| existing == &root_path)
{
canonical.push(root_path);
}
let nested_candidates: Vec<Vec<String>> = searchable
.iter()
.filter(|path| path.first() == Some(&root) && path.len() > 1)
.cloned()
.collect();
for candidate in &nested_candidates {
let is_shadowed = nested_candidates.iter().any(|other| {
other.len() > candidate.len() &&
segment_path_is_prefix(candidate, other)
});
if is_shadowed {
continue;
}
if !canonical.iter().any(|existing| existing == candidate) {
canonical.push(candidate.clone());
}
}
}
canonical
}
fn canonicalize_search_relation_paths<C>(
paths: Vec<Vec<String>>,
) -> Vec<Vec<String>>
where
C: QueryContext,
C::Model: FullTextSearchable,
{
canonicalize_search_relation_paths_with(paths, |segments| {
let refs: Vec<&str> = segments.iter().map(String::as_str).collect();
<C::Model as FullTextSearchable>::resolve_search_join_path(&refs)
.is_some()
})
}
fn parse_relation_path<C>(raw_path: &str) -> Result<JoinPath, WebQueryError>
where
C: QueryContext,
{
let segments: Vec<&str> = raw_path
.split('.')
.map(str::trim)
.filter(|segment| !segment.is_empty())
.collect();
if segments.is_empty() {
return Err(WebQueryError::InvalidRelationPath {
model: std::any::type_name::<C::Model>(),
path: raw_path.to_owned(),
reason: "path is empty".into(),
});
}
<C::Model as sqlxo_traits::WebJoinGraph>::resolve_join_path(
&segments,
JoinKind::Left,
)
.ok_or_else(|| WebQueryError::InvalidRelationPath {
model: std::any::type_name::<C::Model>(),
path: raw_path.to_owned(),
reason: "no matching relation path".into(),
})
}
#[derive(Clone)]
struct ParsedWebSearch {
query: String,
}
impl ParsedWebSearch {
fn new(query: &str) -> Self {
Self {
query: query.to_owned(),
}
}
}
trait SearchApplier<'a, C: QueryContext> {
fn apply(
builder: ReadQueryBuilder<'a, C>,
search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError>;
}
struct SearchBridge<C: QueryContext>(std::marker::PhantomData<C>);
impl<'a, C> SearchApplier<'a, C> for SearchBridge<C>
where
C: QueryContext,
{
default fn apply(
_builder: ReadQueryBuilder<'a, C>,
_search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError> {
Err(WebQueryError::SearchUnsupported {
model: std::any::type_name::<C::Model>(),
})
}
}
impl<'a, C> SearchApplier<'a, C> for SearchBridge<C>
where
C: QueryContext,
C::Model: FullTextSearchable
+ JoinNavigationModel
+ sqlxo_traits::WebJoinGraph
+ 'static,
<C::Model as FullTextSearchable>::FullTextSearchConfig:
FullTextSearchConfigBuilder
+ FullTextSearchJoinConfig<
Join = <C::Model as FullTextSearchable>::FullTextSearchJoin,
> + Send
+ Sync
+ 'static,
{
fn apply(
builder: ReadQueryBuilder<'a, C>,
search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError> {
let mut builder = builder;
let mut config =
<<C::Model as FullTextSearchable>::FullTextSearchConfig as
FullTextSearchConfigBuilder>::new_with_query(
search.query.clone(),
)
.apply_fuzzy(Some(false));
for segments in canonicalize_search_relation_paths::<C>(
default_search_relation_paths::<C>(),
) {
let refs: Vec<&str> = segments.iter().map(String::as_str).collect();
if let Some(join) =
<C::Model as FullTextSearchable>::resolve_search_join_path(
&refs,
) {
if let Some(path) =
<C::Model as sqlxo_traits::WebJoinGraph>::resolve_join_path(
&refs,
JoinKind::Left,
) {
builder = builder.join_path(path);
}
config = config.with_join(join);
}
}
Ok(builder.search(config))
}
}
trait PlainSearchApplier<'a, C: QueryContext> {
fn apply(
builder: ReadQueryBuilder<'a, C>,
search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError>;
}
struct PlainSearchBridge<C: QueryContext>(std::marker::PhantomData<C>);
impl<'a, C> PlainSearchApplier<'a, C> for PlainSearchBridge<C>
where
C: QueryContext,
{
default fn apply(
_builder: ReadQueryBuilder<'a, C>,
_search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError> {
Err(WebQueryError::SearchUnsupported {
model: std::any::type_name::<C::Model>(),
})
}
}
impl<'a, C> PlainSearchApplier<'a, C> for PlainSearchBridge<C>
where
C: QueryContext,
C::Model: FullTextSearchable + 'static,
<C::Model as FullTextSearchable>::FullTextSearchConfig:
FullTextSearchConfigBuilder + Send + Sync + 'static,
{
fn apply(
builder: ReadQueryBuilder<'a, C>,
search: &ParsedWebSearch,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError> {
let config =
<<C::Model as FullTextSearchable>::FullTextSearchConfig as
FullTextSearchConfigBuilder>::new_with_query(
search.query.clone(),
)
.apply_fuzzy(Some(false));
Ok(builder.search(config))
}
}
impl<'a, C> QueryBuilder<C>
where
C: QueryContext,
{
pub fn try_from_web_read<D>(
dto: &WebReadFilter<D>,
) -> Result<ReadQueryBuilder<'a, C>>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::GetDeleteMarker + sqlxo_traits::JoinNavigationModel,
{
ParsedWebReadQuery::<C, D>::new(dto)
.and_then(ParsedWebReadQuery::into_read_builder)
.map_err(crate::SqlxoError::from)
}
pub fn from_web_read<D>(dto: &WebReadFilter<D>) -> ReadQueryBuilder<'a, C>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::GetDeleteMarker + sqlxo_traits::JoinNavigationModel,
{
Self::try_from_web_read::<D>(dto).expect(
"use `QueryBuilder::try_from_web_read` to handle unsupported \
search payloads or other web query validation errors",
)
}
pub fn try_from_web_read_plain<D>(
dto: &WebReadFilter<D>,
) -> Result<ReadQueryBuilder<'a, C>>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::GetDeleteMarker + sqlxo_traits::JoinNavigationModel,
{
ParsedWebReadQuery::<C, D>::new_plain(dto)
.and_then(ParsedWebReadQuery::into_read_builder)
.map_err(crate::SqlxoError::from)
}
pub fn from_web_read_plain<D>(
dto: &WebReadFilter<D>,
) -> ReadQueryBuilder<'a, C>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::GetDeleteMarker + sqlxo_traits::JoinNavigationModel,
{
Self::try_from_web_read_plain::<D>(dto).expect(
"use `QueryBuilder::try_from_web_read_plain` to handle \
unsupported search payloads or other web query validation errors",
)
}
pub fn from_web_update<D>(
dto: &WebUpdateFilter<D>,
) -> UpdateQueryBuilder<'a, C>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::Updatable,
{
apply_mutation_filter::<C, D, UpdateQueryBuilder<'a, C>>(
QueryBuilder::<C>::update().without_auto_joins(),
dto.filter.as_ref(),
)
}
pub fn from_web_delete<D>(
dto: &WebDeleteFilter<D>,
) -> DeleteQueryBuilder<'a, C>
where
D: WebQueryModel + Bind<C>,
C::Model: crate::Deletable,
{
apply_mutation_filter::<C, D, DeleteQueryBuilder<'a, C>>(
QueryBuilder::<C>::delete().without_auto_joins(),
dto.filter.as_ref(),
)
}
}
fn apply_mutation_filter<C, D, B>(
mut builder: B,
filter: Option<&WebExpression<D>>,
) -> B
where
C: QueryContext,
D: WebQueryModel + Bind<C>,
B: BuildableFilter<C>,
{
if let Some(expr) = filter.map(map_expr::<C, D>) {
builder = builder.r#where(expr);
}
builder
}
#[derive(Clone, Copy)]
enum WebReadStyle {
Default,
Plain,
}
#[derive(Clone)]
struct ParsedWebReadQuery<C, D>
where
C: QueryContext,
D: WebQueryModel + Bind<C>,
{
filter_expr: Option<Expression<C::Query>>,
sort_expr: Option<SortOrder<C::Sort>>,
dto_default_paths: Vec<JoinPath>,
style: WebReadStyle,
pagination: Option<Pagination>,
search: Option<ParsedWebSearch>,
_marker: std::marker::PhantomData<D>,
}
impl<C, D> ParsedWebReadQuery<C, D>
where
C: QueryContext,
D: WebQueryModel + Bind<C>,
{
fn new(filter: &WebReadFilter<D>) -> Result<Self, WebQueryError> {
Self::new_with_style(filter, WebReadStyle::Default)
}
fn new_plain(filter: &WebReadFilter<D>) -> Result<Self, WebQueryError> {
Self::new_with_style(filter, WebReadStyle::Plain)
}
fn new_with_style(
filter: &WebReadFilter<D>,
style: WebReadStyle,
) -> Result<Self, WebQueryError> {
let filter_expr = filter.filter.as_ref().map(map_expr::<C, D>);
let sort_expr = filter
.sort
.as_ref()
.and_then(|sorts| if sorts.is_empty() { None } else { Some(sorts) })
.map(|sorts| {
let entries: Vec<C::Sort> = sorts
.iter()
.map(|s| <D as Bind<C>>::map_sort_field(&s.0))
.collect();
SortOrder::from(entries)
});
let dto_default_paths = match style {
WebReadStyle::Default => <D as Bind<C>>::default_join_paths()
.iter()
.map(|path| parse_relation_path::<C>(path))
.collect::<Result<Vec<_>, _>>()?,
WebReadStyle::Plain => Vec::new(),
};
let pagination = match (filter.page_no, filter.page_size) {
(None, None) => None,
(page_no, page_size) => {
let defaults = WebPagination::default();
Some(Pagination {
page: page_no.unwrap_or(defaults.page),
page_size: page_size.unwrap_or(defaults.page_size),
})
}
};
let search = filter.search.as_deref().map(ParsedWebSearch::new);
Ok(Self {
filter_expr,
sort_expr,
dto_default_paths,
style,
pagination,
search,
_marker: std::marker::PhantomData,
})
}
fn into_read_builder<'a>(
self,
) -> Result<ReadQueryBuilder<'a, C>, WebQueryError>
where
C::Model: crate::GetDeleteMarker + sqlxo_traits::JoinNavigationModel,
{
let ParsedWebReadQuery {
filter_expr,
sort_expr,
dto_default_paths,
style,
pagination,
search,
..
} = self;
let mut builder = match style {
WebReadStyle::Default => QueryBuilder::<C>::read(),
WebReadStyle::Plain => {
QueryBuilder::<C>::read().without_auto_joins()
}
};
if let WebReadStyle::Default = style {
if dto_default_paths.is_empty() {
builder = builder.include_lazy_relations();
} else {
for path in dto_default_paths {
builder = builder.join_path(path);
}
}
}
if let Some(expr) = filter_expr {
builder = builder.r#where(expr);
}
if let Some(search) = search {
builder = match style {
WebReadStyle::Default => {
SearchBridge::<C>::apply(builder, &search)?
}
WebReadStyle::Plain => {
PlainSearchBridge::<C>::apply(builder, &search)?
}
};
}
if let Some(sort) = sort_expr {
builder = builder.order_by(sort);
}
if let Some(page) = pagination {
builder = builder.paginate(page);
}
Ok(builder)
}
}
#[cfg(test)]
mod tests {
use super::canonicalize_search_relation_paths_with;
fn path(segments: &[&str]) -> Vec<String> {
segments.iter().map(|segment| segment.to_string()).collect()
}
#[test]
fn canonicalization_keeps_direct_and_deepest_nested_paths() {
let input = vec![
path(&["stock"]),
path(&["stock", "stored_at"]),
path(&["stock", "stored_at", "children"]),
path(&["stock", "stored_at", "children"]),
path(&["properties"]),
path(&["properties", "property_type"]),
path(&["properties", "property_type", "units"]),
path(&["properties", "unit"]),
path(&["unit"]),
path(&["unit", "property_types"]),
];
let result =
canonicalize_search_relation_paths_with(input, |segments| {
!segments.is_empty()
});
assert_eq!(result, vec![
path(&["stock"]),
path(&["stock", "stored_at", "children"]),
path(&["properties"]),
path(&["properties", "property_type", "units"]),
path(&["properties", "unit"]),
path(&["unit"]),
path(&["unit", "property_types"]),
]);
}
#[test]
fn canonicalization_skips_non_searchable_direct_roots() {
let input = vec![path(&["a"]), path(&["a", "b"]), path(&["c"])];
let result =
canonicalize_search_relation_paths_with(input, |segments| {
!(segments.len() == 1 && segments[0] == "a")
});
assert_eq!(result, vec![path(&["a", "b"]), path(&["c"])]);
}
}