use crate::model::Model;
use crate::pg::accumulator::SqlAccumulator;
use crate::query::predicate::PortablePredicate;
use sassi::BasicPredicate;
use std::any::TypeId;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PortablePredicateError {
#[error("model does not support portable SQL lowering: {model}")]
UnsupportedModel {
model: &'static str,
},
#[error("field {field} does not support portable SQL lowering")]
UnsupportedField {
field: &'static str,
},
#[error("field {field} lookup {op:?} is not portable to SQL")]
UnsupportedLookup {
field: &'static str,
op: crate::types::LookupOp,
},
#[error("field {field} lookup {op:?} had an unexpected payload type")]
ValueTypeMismatch {
field: &'static str,
op: crate::types::LookupOp,
},
#[error("field {field} type is not supported by portable SQL lowering")]
UnsupportedFieldType {
field: &'static str,
},
#[error(
"lateral outer ref {source_model}.{column} is out of scope; \
it can only be used inside a lateral inner query"
)]
LateralOuterRefOutOfScope {
column: &'static str,
source_model: &'static str,
},
#[error(
"lateral outer ref model mismatch for column {column}: source model \
{source_model} does not match lateral outer model {expected_model}"
)]
LateralOuterRefModelMismatch {
column: &'static str,
source_model: &'static str,
expected_model: &'static str,
},
#[error("query node {kind} cannot be used as a portable cache predicate")]
CacheInvalidNode {
kind: &'static str,
},
#[error("Sassi predicate variant {kind} is not supported by Djogi SQL lowering")]
UnsupportedPredicateKind {
kind: &'static str,
},
#[error(
"field {field} JSON predicate lacks Djogi trusted provenance — \
construct through `DjogiField<M, MirJzSON>::jsahibon()` instead of \
raw `sassi::Field::new(...).jsahibon()`"
)]
UntrustedJsonPredicate {
field: &'static str,
},
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Default)]
pub struct SqlEmitContext {
parent_table: Option<&'static str>,
lateral_outer: Option<LateralOuterScope>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct LateralOuterScope {
pub(crate) alias: &'static str,
pub(crate) model_type: TypeId,
pub(crate) model_name: &'static str,
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum JsonTrust {
Trusted,
#[allow(dead_code)]
Untrusted,
}
impl SqlEmitContext {
#[doc(hidden)]
pub const fn root() -> Self {
Self {
parent_table: None,
lateral_outer: None,
}
}
#[doc(hidden)]
pub const fn joined(parent_table: &'static str) -> Self {
Self {
parent_table: Some(parent_table),
lateral_outer: None,
}
}
#[doc(hidden)]
pub fn lateral_inner_scope<L: Model>(alias: &'static str) -> Self {
Self {
parent_table: None,
lateral_outer: Some(LateralOuterScope {
alias,
model_type: TypeId::of::<L>(),
model_name: std::any::type_name::<L>(),
}),
}
}
#[doc(hidden)]
pub fn push_column(self, acc: &mut SqlAccumulator, column: &'static str) {
if column.contains('.') {
acc.push_sql(column);
return;
}
if let Some(table) = self.parent_table {
acc.push_sql(table);
acc.push_sql(".");
}
acc.push_sql(column);
}
#[doc(hidden)]
#[allow(dead_code)] pub(crate) const fn parent_table(self) -> Option<&'static str> {
self.parent_table
}
#[doc(hidden)]
pub(crate) fn lateral_outer_scope(self) -> Option<LateralOuterScope> {
self.lateral_outer
}
#[doc(hidden)]
pub(crate) const fn subquery_body(self) -> Self {
Self {
parent_table: None,
lateral_outer: self.lateral_outer,
}
}
}
#[doc(hidden)]
pub(crate) fn emit_portable_predicate<T: Model>(
acc: &mut SqlAccumulator,
predicate: &PortablePredicate<T>,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
emit_basic_predicate::<T>(acc, predicate.inner_ref(), ctx, JsonTrust::Trusted)
}
pub(crate) fn emit_basic_predicate<T: Model>(
acc: &mut SqlAccumulator,
bp: &BasicPredicate<T>,
ctx: SqlEmitContext,
trust: JsonTrust,
) -> Result<(), PortablePredicateError> {
match bp {
BasicPredicate::True => {
acc.push_sql("TRUE");
Ok(())
}
BasicPredicate::False => {
acc.push_sql("FALSE");
Ok(())
}
BasicPredicate::Field(fp) => {
if matches!(fp.op(), crate::types::LookupOp::Json) {
if !matches!(trust, JsonTrust::Trusted) {
return Err(PortablePredicateError::UntrustedJsonPredicate {
field: fp.field_name(),
});
}
emit_jsahibon_predicate::<T>(acc, fp, ctx)
} else {
T::__djogi_emit_field_predicate(acc, fp, ctx)
}
}
BasicPredicate::And(parts) => {
if parts.is_empty() {
acc.push_sql("TRUE");
return Ok(());
}
acc.push_sql("(");
for (i, p) in parts.iter().enumerate() {
if i > 0 {
acc.push_sql(" AND ");
}
emit_basic_predicate::<T>(acc, p, ctx, trust)?;
}
acc.push_sql(")");
Ok(())
}
BasicPredicate::Or(parts) => {
if parts.is_empty() {
acc.push_sql("FALSE");
return Ok(());
}
acc.push_sql("(");
for (i, p) in parts.iter().enumerate() {
if i > 0 {
acc.push_sql(" OR ");
}
emit_basic_predicate::<T>(acc, p, ctx, trust)?;
}
acc.push_sql(")");
Ok(())
}
BasicPredicate::Not(inner) => {
acc.push_sql("NOT (");
emit_basic_predicate::<T>(acc, inner, ctx, trust)?;
acc.push_sql(")");
Ok(())
}
BasicPredicate::Xor(a, b) => {
acc.push_sql("(((NOT (");
emit_basic_predicate::<T>(acc, a, ctx, trust)?;
acc.push_sql(")) AND (");
emit_basic_predicate::<T>(acc, b, ctx, trust)?;
acc.push_sql(")) OR ((");
emit_basic_predicate::<T>(acc, a, ctx, trust)?;
acc.push_sql(") AND (NOT (");
emit_basic_predicate::<T>(acc, b, ctx, trust)?;
acc.push_sql("))))");
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "BasicPredicate::<unknown>",
}),
}
}
use crate::types::FieldPredicate;
use sassi::JSahibON;
use sassi::predicate::{
JCompareOp, JInPolarity, JPath, JSahibONPredicateBody, JScalarKind, JScalarValue, JTypeKind,
};
fn emit_jsahibon_predicate<T: Model>(
acc: &mut SqlAccumulator,
fp: &FieldPredicate<T>,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
let body: &JSahibONPredicateBody = match fp.value_as::<JSahibONPredicateBody>() {
Some(body) => body,
None => {
return Err(PortablePredicateError::UntrustedJsonPredicate {
field: fp.field_name(),
});
}
};
emit_jsahibon_body(acc, fp.field_name(), body, ctx)
}
fn push_j_expression(
acc: &mut SqlAccumulator,
column: &'static str,
path: &JPath,
ctx: SqlEmitContext,
) {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" #> ");
let segments: Vec<String> = path.segments().to_vec();
acc.push_bind(segments);
acc.push_sql(")");
}
fn push_key_array_bind(acc: &mut SqlAccumulator, keys: &[String]) {
let owned: Vec<String> = keys.to_vec();
acc.push_bind(owned);
}
fn push_jsonb_value_bind(acc: &mut SqlAccumulator, value: &JSahibON) {
let json: serde_json::Value = value.clone().into();
acc.push_bind(json);
}
fn push_numeric_bind(
acc: &mut SqlAccumulator,
operand: &JScalarValue,
) -> Result<(), PortablePredicateError> {
match operand {
JScalarValue::I64(value) => {
acc.push_bind(rust_decimal::Decimal::from(*value));
Ok(())
}
JScalarValue::U64(value) => {
acc.push_bind(rust_decimal::Decimal::from(*value));
Ok(())
}
JScalarValue::F64(value) => {
match rust_decimal::Decimal::try_from(value.get()) {
Ok(d) => {
acc.push_bind(d);
Ok(())
}
Err(_) => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "JSahibON::F64 operand exceeds Decimal range",
}),
}
}
JScalarValue::String(_) | JScalarValue::Bool(_) => {
Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "non-numeric operand in numeric JSON comparison",
})
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "JSahibON scalar operand variant unknown to Djogi SQL emission",
}),
}
}
fn push_text_bind(
acc: &mut SqlAccumulator,
operand: &JScalarValue,
) -> Result<(), PortablePredicateError> {
match operand {
JScalarValue::String(value) => {
acc.push_bind(value.clone());
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "non-string operand in string JSON comparison",
}),
}
}
fn push_bool_bind(
acc: &mut SqlAccumulator,
operand: &JScalarValue,
) -> Result<(), PortablePredicateError> {
match operand {
JScalarValue::Bool(value) => {
acc.push_bind(*value);
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "non-boolean operand in boolean JSON comparison",
}),
}
}
fn compare_op_token(op: JCompareOp) -> &'static str {
match op {
JCompareOp::Eq => " = ",
JCompareOp::Neq => " <> ",
JCompareOp::Gt => " > ",
JCompareOp::Gte => " >= ",
JCompareOp::Lt => " < ",
JCompareOp::Lte => " <= ",
_ => " = ",
}
}
fn jsonb_typeof_literal(kind: JTypeKind) -> &'static str {
match kind {
JTypeKind::Null => "'null'",
JTypeKind::Bool => "'boolean'",
JTypeKind::Number => "'number'",
JTypeKind::String => "'string'",
JTypeKind::Array => "'array'",
JTypeKind::Object => "'object'",
_ => "'null'",
}
}
fn emit_jsahibon_body(
acc: &mut SqlAccumulator,
column: &'static str,
body: &JSahibONPredicateBody,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
match body {
JSahibONPredicateBody::Exists { path } => {
push_j_expression(acc, column, path, ctx);
acc.push_sql(" IS NOT NULL");
Ok(())
}
JSahibONPredicateBody::Missing { path } => {
push_j_expression(acc, column, path, ctx);
acc.push_sql(" IS NULL");
Ok(())
}
JSahibONPredicateBody::IsJsonNull { path } => {
acc.push_sql("COALESCE(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" = 'null'::jsonb, FALSE)");
Ok(())
}
JSahibONPredicateBody::IsNotJsonNull { path } => {
acc.push_sql("COALESCE(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" <> 'null'::jsonb, FALSE)");
Ok(())
}
JSahibONPredicateBody::Type { path, kind } => {
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = ");
acc.push_sql(jsonb_typeof_literal(*kind));
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::HasKey { path, key } => {
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'object' AND ");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" ? ");
acc.push_bind(key.as_str().to_owned());
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::HasAnyKey { path, keys } => {
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'object' AND ");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" ?| ");
push_key_array_bind(acc, keys);
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::HasAllKeys { path, keys } => {
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'object' AND ");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" ?& ");
push_key_array_bind(acc, keys);
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::ScalarCompare {
path,
op,
scalar_kind,
operand,
} => emit_scalar_compare(acc, column, path, *op, *scalar_kind, operand, ctx),
JSahibONPredicateBody::ScalarIn {
path,
scalar_kind,
operands,
polarity,
} => emit_scalar_in(acc, column, path, *scalar_kind, operands, *polarity, ctx),
JSahibONPredicateBody::ScalarBetween {
path,
scalar_kind,
low,
high,
} => emit_scalar_between(acc, column, path, *scalar_kind, low, high, ctx),
JSahibONPredicateBody::JsonEq { path, value } => {
acc.push_sql("COALESCE(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" = ");
push_jsonb_value_bind(acc, value);
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::JsonNeq { path, value } => {
acc.push_sql("COALESCE(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" <> ");
push_jsonb_value_bind(acc, value);
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::ArrayContains { path, element } => {
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'array' AND ");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" @> ");
let array = serde_json::Value::Array(vec![element.clone().into()]);
acc.push_bind(array);
acc.push_sql(", FALSE)");
Ok(())
}
JSahibONPredicateBody::ArrayLen { path, op, len } => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'array' THEN jsonb_array_length(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(")");
acc.push_sql(compare_op_token(*op));
acc.push_bind(rust_decimal::Decimal::from(*len));
acc.push_sql(" ELSE FALSE END");
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "JSahibONPredicateBody::<unknown>",
}),
}
}
fn emit_scalar_compare(
acc: &mut SqlAccumulator,
column: &'static str,
path: &JPath,
op: JCompareOp,
scalar_kind: JScalarKind,
operand: &JScalarValue,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
match scalar_kind {
JScalarKind::I64 | JScalarKind::U64 | JScalarKind::F64 => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'number' THEN (");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])::numeric");
acc.push_sql(compare_op_token(op));
push_numeric_bind(acc, operand)?;
acc.push_sql(" ELSE FALSE END");
Ok(())
}
JScalarKind::String => {
if matches!(
op,
JCompareOp::Gt | JCompareOp::Gte | JCompareOp::Lt | JCompareOp::Lte
) {
return Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "ordering operator on JSON string operand",
});
}
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'string' AND (");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])");
acc.push_sql(compare_op_token(op));
push_text_bind(acc, operand)?;
acc.push_sql(", FALSE)");
Ok(())
}
JScalarKind::Bool => {
if matches!(
op,
JCompareOp::Gt | JCompareOp::Gte | JCompareOp::Lt | JCompareOp::Lte
) {
return Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "ordering operator on JSON boolean operand",
});
}
acc.push_sql("COALESCE(jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'boolean' AND ((");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])::boolean");
acc.push_sql(compare_op_token(op));
push_bool_bind(acc, operand)?;
acc.push_sql("), FALSE)");
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "JSahibON scalar kind unknown to Djogi SQL emission",
}),
}
}
fn emit_scalar_in(
acc: &mut SqlAccumulator,
column: &'static str,
path: &JPath,
scalar_kind: JScalarKind,
operands: &[JScalarValue],
polarity: JInPolarity,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
let in_token = match polarity {
JInPolarity::In => " IN (",
JInPolarity::NotIn => " NOT IN (",
_ => " IN (",
};
match scalar_kind {
JScalarKind::I64 | JScalarKind::U64 | JScalarKind::F64 => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'number' THEN ");
if operands.is_empty() {
acc.push_sql(match polarity {
JInPolarity::In => "FALSE",
JInPolarity::NotIn => "TRUE",
_ => "FALSE",
});
} else {
acc.push_sql("((");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])::numeric");
acc.push_sql(in_token);
for (i, operand) in operands.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
push_numeric_bind(acc, operand)?;
}
acc.push_sql("))");
}
acc.push_sql(" ELSE FALSE END");
Ok(())
}
JScalarKind::String => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'string' THEN ");
if operands.is_empty() {
acc.push_sql(match polarity {
JInPolarity::In => "FALSE",
JInPolarity::NotIn => "TRUE",
_ => "FALSE",
});
} else {
acc.push_sql("((");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])");
acc.push_sql(in_token);
for (i, operand) in operands.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
push_text_bind(acc, operand)?;
}
acc.push_sql("))");
}
acc.push_sql(" ELSE FALSE END");
Ok(())
}
JScalarKind::Bool => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'boolean' THEN ");
if operands.is_empty() {
acc.push_sql(match polarity {
JInPolarity::In => "FALSE",
JInPolarity::NotIn => "TRUE",
_ => "FALSE",
});
} else {
acc.push_sql("(((");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])::boolean)");
acc.push_sql(in_token);
for (i, operand) in operands.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
push_bool_bind(acc, operand)?;
}
acc.push_sql("))");
}
acc.push_sql(" ELSE FALSE END");
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "JSahibON scalar kind unknown to Djogi SQL emission",
}),
}
}
fn emit_scalar_between(
acc: &mut SqlAccumulator,
column: &'static str,
path: &JPath,
scalar_kind: JScalarKind,
low: &JScalarValue,
high: &JScalarValue,
ctx: SqlEmitContext,
) -> Result<(), PortablePredicateError> {
match scalar_kind {
JScalarKind::I64 | JScalarKind::U64 | JScalarKind::F64 => {
acc.push_sql("CASE WHEN jsonb_typeof(");
push_j_expression(acc, column, path, ctx);
acc.push_sql(") = 'number' THEN ((");
push_j_expression(acc, column, path, ctx);
acc.push_sql(" #>> '{}'::text[])::numeric BETWEEN ");
push_numeric_bind(acc, low)?;
acc.push_sql(" AND ");
push_numeric_bind(acc, high)?;
acc.push_sql(") ELSE FALSE END");
Ok(())
}
_ => Err(PortablePredicateError::UnsupportedPredicateKind {
kind: "BETWEEN on non-numeric JSON operand",
}),
}
}
#[doc(hidden)]
pub mod emit {
use super::{PortablePredicateError, SqlEmitContext};
use crate::descriptor::{BoxedSqlBind, EnumPredicateCodec, FieldSqlType};
use crate::model::Model;
use crate::pg::accumulator::SqlAccumulator;
use crate::types::{FieldPredicate, LookupOp};
#[derive(Clone, Copy, Debug)]
#[doc(hidden)]
pub enum PatternOp {
Contains,
IContains,
StartsWith,
IStartsWith,
EndsWith,
IEndsWith,
IExact,
}
#[doc(hidden)]
pub fn emit_value<M, V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
op_sql: &'static str,
field: &FieldPredicate<M>,
) -> Result<(), PortablePredicateError>
where
M: Model,
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
let Some(value) = field.value_as::<V>() else {
return Err(PortablePredicateError::ValueTypeMismatch {
field: field.field_name(),
op: field.op(),
});
};
ctx.push_column(acc, column);
acc.push_sql(op_sql);
acc.push_bind(value.clone());
Ok(())
}
#[doc(hidden)]
pub fn emit_value_ref<V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
op_sql: &'static str,
value: &V,
) -> Result<(), PortablePredicateError>
where
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
ctx.push_column(acc, column);
acc.push_sql(op_sql);
acc.push_bind(value.clone());
Ok(())
}
#[doc(hidden)]
pub fn emit_pair<M, V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
field: &FieldPredicate<M>,
) -> Result<(), PortablePredicateError>
where
M: Model,
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
let Some(pair) = field.value_as::<(V, V)>() else {
return Err(PortablePredicateError::ValueTypeMismatch {
field: field.field_name(),
op: field.op(),
});
};
ctx.push_column(acc, column);
acc.push_sql(" BETWEEN ");
acc.push_bind(pair.0.clone());
acc.push_sql(" AND ");
acc.push_bind(pair.1.clone());
Ok(())
}
#[doc(hidden)]
pub fn emit_list<M, V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
field: &FieldPredicate<M>,
negated: bool,
) -> Result<(), PortablePredicateError>
where
M: Model,
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
let Some(values) = field.value_as::<Vec<V>>() else {
return Err(PortablePredicateError::ValueTypeMismatch {
field: field.field_name(),
op: field.op(),
});
};
if values.is_empty() {
acc.push_sql(if negated { "TRUE" } else { "FALSE" });
return Ok(());
}
ctx.push_column(acc, column);
acc.push_sql(if negated { " NOT IN (" } else { " IN (" });
for (i, v) in values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind(v.clone());
}
acc.push_sql(")");
Ok(())
}
#[doc(hidden)]
pub fn emit_registered_custom<M, V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
field: &FieldPredicate<M>,
) -> Result<(), PortablePredicateError>
where
M: Model,
V: 'static,
{
let Some(field_descriptor) = M::descriptor()
.fields
.iter()
.find(|field_descriptor| field_descriptor.name == column)
else {
return Err(PortablePredicateError::UnsupportedFieldType { field: column });
};
if field_descriptor.protected.is_some() {
return Err(PortablePredicateError::UnsupportedFieldType { field: column });
}
let postgres_type = match &field_descriptor.sql_type {
FieldSqlType::Custom(postgres_type) => *postgres_type,
_ => return Err(PortablePredicateError::UnsupportedFieldType { field: column }),
};
let mut saw_matching_field_type = false;
let field_type = std::any::TypeId::of::<V>();
for codec in inventory::iter::<EnumPredicateCodec> {
if codec.postgres_type != postgres_type {
continue;
}
if !(codec.matches_field_type)(field_type) {
continue;
}
saw_matching_field_type = true;
match field.op() {
LookupOp::Eq => {
if field_descriptor.nullable
&& let Some(value) = (codec.bind_option_value)(field.value())
{
return emit_boxed_option_eq(acc, ctx, column, value);
}
if let Some(value) = (codec.bind_value)(field.value()) {
return emit_boxed_value(acc, ctx, column, " = ", value);
}
}
LookupOp::Neq => {
if field_descriptor.nullable
&& let Some(value) = (codec.bind_option_value)(field.value())
{
return emit_boxed_option_neq(acc, ctx, column, value);
}
if let Some(value) = (codec.bind_value)(field.value()) {
return emit_boxed_value(acc, ctx, column, " <> ", value);
}
}
LookupOp::In => {
if field_descriptor.nullable
&& let Some(values) = (codec.bind_option_list)(field.value())
{
return emit_boxed_option_list(acc, ctx, column, values, false);
}
if let Some(values) = (codec.bind_list)(field.value()) {
if field_descriptor.nullable {
return emit_boxed_present_list(acc, ctx, column, values, false);
}
return emit_boxed_list(acc, ctx, column, values, false);
}
}
LookupOp::NotIn => {
if field_descriptor.nullable
&& let Some(values) = (codec.bind_option_list)(field.value())
{
return emit_boxed_option_list(acc, ctx, column, values, true);
}
if let Some(values) = (codec.bind_list)(field.value()) {
if field_descriptor.nullable {
return emit_boxed_present_list(acc, ctx, column, values, true);
}
return emit_boxed_list(acc, ctx, column, values, true);
}
}
LookupOp::IsNull => {
ctx.push_column(acc, column);
acc.push_sql(" IS NULL");
return Ok(());
}
LookupOp::IsNotNull => {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
return Ok(());
}
op => {
return Err(PortablePredicateError::UnsupportedLookup { field: column, op });
}
}
}
if saw_matching_field_type {
Err(PortablePredicateError::ValueTypeMismatch {
field: column,
op: field.op(),
})
} else {
Err(PortablePredicateError::UnsupportedFieldType { field: column })
}
}
fn emit_boxed_value(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
op_sql: &'static str,
value: BoxedSqlBind,
) -> Result<(), PortablePredicateError> {
ctx.push_column(acc, column);
acc.push_sql(op_sql);
acc.push_boxed_bind(value);
Ok(())
}
fn emit_boxed_list(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
values: Vec<BoxedSqlBind>,
negated: bool,
) -> Result<(), PortablePredicateError> {
if values.is_empty() {
acc.push_sql(if negated { "TRUE" } else { "FALSE" });
return Ok(());
}
ctx.push_column(acc, column);
acc.push_sql(if negated { " NOT IN (" } else { " IN (" });
for (i, value) in values.into_iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_boxed_bind(value);
}
acc.push_sql(")");
Ok(())
}
fn emit_boxed_present_list(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
values: Vec<BoxedSqlBind>,
negated: bool,
) -> Result<(), PortablePredicateError> {
if values.is_empty() {
if negated {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
} else {
acc.push_sql("FALSE");
}
return Ok(());
}
if negated {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL AND ");
ctx.push_column(acc, column);
acc.push_sql(" NOT IN (");
} else {
ctx.push_column(acc, column);
acc.push_sql(" IN (");
}
for (i, value) in values.into_iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_boxed_bind(value);
}
if negated {
acc.push_sql("))");
} else {
acc.push_sql(")");
}
Ok(())
}
fn emit_boxed_option_eq(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
value: Option<BoxedSqlBind>,
) -> Result<(), PortablePredicateError> {
match value {
Some(value) => emit_boxed_value(acc, ctx, column, " = ", value),
None => {
ctx.push_column(acc, column);
acc.push_sql(" IS NULL");
Ok(())
}
}
}
fn emit_boxed_option_neq(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
value: Option<BoxedSqlBind>,
) -> Result<(), PortablePredicateError> {
match value {
Some(value) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
ctx.push_column(acc, column);
acc.push_sql(" <> ");
acc.push_boxed_bind(value);
acc.push_sql(")");
Ok(())
}
None => {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
Ok(())
}
}
}
fn emit_boxed_option_list(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
values: Vec<Option<BoxedSqlBind>>,
negated: bool,
) -> Result<(), PortablePredicateError> {
if values.is_empty() {
acc.push_sql(if negated { "TRUE" } else { "FALSE" });
return Ok(());
}
let has_none = values.iter().any(Option::is_none);
let some_values: Vec<BoxedSqlBind> = values.into_iter().flatten().collect();
if !negated {
match (has_none, some_values.is_empty()) {
(true, true) => {
ctx.push_column(acc, column);
acc.push_sql(" IS NULL");
}
(false, false) => emit_boxed_list(acc, ctx, column, some_values, false)?,
(true, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
emit_boxed_list(acc, ctx, column, some_values, false)?;
acc.push_sql(")");
}
(false, true) => unreachable!("non-empty values with no None and no Some"),
}
} else {
match (has_none, some_values.is_empty()) {
(true, true) => {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
}
(false, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
emit_boxed_list(acc, ctx, column, some_values, true)?;
acc.push_sql(")");
}
(true, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL AND ");
ctx.push_column(acc, column);
acc.push_sql(" NOT IN (");
for (i, value) in some_values.into_iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_boxed_bind(value);
}
acc.push_sql("))");
}
(false, true) => unreachable!("non-empty values with no None and no Some"),
}
}
Ok(())
}
#[doc(hidden)]
pub fn emit_present_list<V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
values: &[V],
negated: bool,
) -> Result<(), PortablePredicateError>
where
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
if values.is_empty() {
if negated {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
} else {
acc.push_sql("FALSE");
}
return Ok(());
}
if negated {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL AND ");
ctx.push_column(acc, column);
acc.push_sql(" NOT IN (");
} else {
ctx.push_column(acc, column);
acc.push_sql(" IN (");
}
for (i, v) in values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind(v.clone());
}
if negated {
acc.push_sql("))");
} else {
acc.push_sql(")");
}
Ok(())
}
#[doc(hidden)]
pub fn emit_string_pattern<M>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
op: PatternOp,
field: &FieldPredicate<M>,
) -> Result<(), PortablePredicateError>
where
M: Model,
{
let Some(value) = field.value_as::<String>() else {
return Err(PortablePredicateError::ValueTypeMismatch {
field: field.field_name(),
op: field.op(),
});
};
let escaped = escape_like(value);
match op {
PatternOp::Contains => {
ctx.push_column(acc, column);
acc.push_sql(" LIKE ");
acc.push_bind(format!("%{escaped}%"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::IContains => {
ctx.push_column(acc, column);
acc.push_sql(" COLLATE \"C\" ILIKE ");
acc.push_bind(format!("%{escaped}%"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::StartsWith => {
ctx.push_column(acc, column);
acc.push_sql(" LIKE ");
acc.push_bind(format!("{escaped}%"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::IStartsWith => {
ctx.push_column(acc, column);
acc.push_sql(" COLLATE \"C\" ILIKE ");
acc.push_bind(format!("{escaped}%"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::EndsWith => {
ctx.push_column(acc, column);
acc.push_sql(" LIKE ");
acc.push_bind(format!("%{escaped}"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::IEndsWith => {
ctx.push_column(acc, column);
acc.push_sql(" COLLATE \"C\" ILIKE ");
acc.push_bind(format!("%{escaped}"));
acc.push_sql(" ESCAPE '\\'");
}
PatternOp::IExact => {
ctx.push_column(acc, column);
acc.push_sql(" COLLATE \"C\" ILIKE ");
acc.push_bind(escaped);
acc.push_sql(" ESCAPE '\\'");
}
}
Ok(())
}
#[doc(hidden)]
pub fn emit_null(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
is_null: bool,
) -> Result<(), PortablePredicateError> {
ctx.push_column(acc, column);
if is_null {
acc.push_sql(" IS NULL");
} else {
acc.push_sql(" IS NOT NULL");
}
Ok(())
}
#[doc(hidden)]
pub fn emit_option_eq<V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
value: &Option<V>,
) -> Result<(), PortablePredicateError>
where
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
match value {
Some(v) => {
ctx.push_column(acc, column);
acc.push_sql(" = ");
acc.push_bind(v.clone());
}
None => {
ctx.push_column(acc, column);
acc.push_sql(" IS NULL");
}
}
Ok(())
}
#[doc(hidden)]
pub fn emit_option_neq<V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
value: &Option<V>,
) -> Result<(), PortablePredicateError>
where
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
match value {
Some(v) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
ctx.push_column(acc, column);
acc.push_sql(" <> ");
acc.push_bind(v.clone());
acc.push_sql(")");
}
None => {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
}
}
Ok(())
}
#[doc(hidden)]
pub fn emit_option_in<V>(
acc: &mut SqlAccumulator,
ctx: SqlEmitContext,
column: &'static str,
values: &[Option<V>],
negated: bool,
) -> Result<(), PortablePredicateError>
where
V: postgres_types::ToSql + Clone + Send + Sync + 'static,
{
if values.is_empty() {
acc.push_sql(if negated { "TRUE" } else { "FALSE" });
return Ok(());
}
let has_none = values.iter().any(Option::is_none);
let some_values: Vec<&V> = values.iter().filter_map(|v| v.as_ref()).collect();
if !negated {
match (has_none, some_values.is_empty()) {
(true, true) => {
ctx.push_column(acc, column);
acc.push_sql(" IS NULL");
}
(false, false) => {
ctx.push_column(acc, column);
acc.push_sql(" IN (");
for (i, v) in some_values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind((*v).clone());
}
acc.push_sql(")");
}
(true, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
ctx.push_column(acc, column);
acc.push_sql(" IN (");
for (i, v) in some_values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind((*v).clone());
}
acc.push_sql("))");
}
(false, true) => unreachable!("non-empty values with no None and no Some"),
}
} else {
match (has_none, some_values.is_empty()) {
(true, true) => {
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL");
}
(false, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NULL OR ");
ctx.push_column(acc, column);
acc.push_sql(" NOT IN (");
for (i, v) in some_values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind((*v).clone());
}
acc.push_sql("))");
}
(true, false) => {
acc.push_sql("(");
ctx.push_column(acc, column);
acc.push_sql(" IS NOT NULL AND ");
ctx.push_column(acc, column);
acc.push_sql(" NOT IN (");
for (i, v) in some_values.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_bind((*v).clone());
}
acc.push_sql("))");
}
(false, true) => unreachable!("non-empty values with no None and no Some"),
}
}
Ok(())
}
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '%' | '_' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
pub use crate::types::LookupOp as _LookupOp;
#[allow(dead_code)]
fn _ensure_lookup_op_visible(_op: LookupOp) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_context_emits_bare_column() {
let mut acc = SqlAccumulator::new("");
SqlEmitContext::root().push_column(&mut acc, "title");
let (sql, _) = acc.into_parts();
assert_eq!(sql, "title");
}
#[test]
fn joined_context_qualifies_bare_column() {
let mut acc = SqlAccumulator::new("");
SqlEmitContext::joined("posts").push_column(&mut acc, "title");
let (sql, _) = acc.into_parts();
assert_eq!(sql, "posts.title");
}
#[test]
fn dotted_column_is_emitted_as_is_under_root() {
let mut acc = SqlAccumulator::new("");
SqlEmitContext::root().push_column(&mut acc, "author.name");
let (sql, _) = acc.into_parts();
assert_eq!(sql, "author.name");
}
#[test]
fn dotted_column_is_emitted_as_is_under_joined() {
let mut acc = SqlAccumulator::new("");
SqlEmitContext::joined("posts").push_column(&mut acc, "author.name");
let (sql, _) = acc.into_parts();
assert_eq!(sql, "author.name");
}
#[test]
fn parent_table_accessor_returns_stored_value() {
assert_eq!(SqlEmitContext::root().parent_table(), None);
assert_eq!(SqlEmitContext::joined("t").parent_table(), Some("t"));
}
#[test]
fn lateral_scope_accessor_returns_outer_model_metadata() {
let ctx = SqlEmitContext::lateral_inner_scope::<TestModel>("l");
assert_eq!(ctx.parent_table(), None);
let scope = ctx
.lateral_outer_scope()
.expect("lateral scope should be present");
assert_eq!(scope.alias, "l");
assert_eq!(scope.model_type, std::any::TypeId::of::<TestModel>());
assert!(scope.model_name.contains("TestModel"));
}
use crate::model::Model;
use sassi::BasicPredicate;
use sassi::Field as SassiField;
#[allow(dead_code)]
#[derive(Debug)]
struct TestModel {
id: i64,
score: i32,
name: String,
active: bool,
maybe_year: Option<i32>,
payload: sassi::JSahibON,
}
impl crate::model::__sealed::Sealed for TestModel {}
#[allow(clippy::manual_async_fn)]
impl Model for TestModel {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"test_models"
}
fn pk_value(&self) -> &i64 {
&self.id
}
fn descriptor() -> &'static crate::descriptor::ModelDescriptor {
unimplemented!()
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: i64,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unimplemented!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unimplemented!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unimplemented!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unimplemented!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unimplemented!() }
}
}
fn unwrap_field_pred<M: std::fmt::Debug>(
bp: BasicPredicate<M>,
) -> sassi::predicate::FieldPredicate<M> {
match bp {
BasicPredicate::Field(fp) => fp,
other => panic!("expected Field predicate, got {other:?}"),
}
}
#[test]
fn emit_option_eq_some_value_uses_equals_bind() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_eq::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&Some(2020),
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year = $1");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_option_eq_none_uses_is_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_eq::<i32>(&mut acc, SqlEmitContext::root(), "estimated_year", &None)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NULL");
assert!(binds.is_empty());
}
#[test]
fn emit_option_neq_some_value_uses_null_or_neq() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_neq::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&Some(2020),
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "(estimated_year IS NULL OR estimated_year <> $1)");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_option_neq_none_uses_is_not_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_neq::<i32>(&mut acc, SqlEmitContext::root(), "estimated_year", &None)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NOT NULL");
assert!(binds.is_empty());
}
#[test]
fn emit_option_in_empty_returns_false_literal() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[],
false,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "FALSE");
assert!(binds.is_empty());
}
#[test]
fn emit_option_not_in_empty_returns_true_literal() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[],
true,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "TRUE");
assert!(binds.is_empty());
}
#[test]
fn emit_option_in_only_none_uses_is_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[None],
false,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NULL");
}
#[test]
fn emit_option_in_only_some_uses_in_list() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[Some(2019), Some(2020)],
false,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year IN ($1, $2)");
assert_eq!(binds.len(), 2);
}
#[test]
fn emit_option_in_mixed_none_and_some_unions_predicates() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[None, Some(2020)],
false,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "(estimated_year IS NULL OR estimated_year IN ($1))");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_option_not_in_mixed_intersects_predicates() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[None, Some(2020)],
true,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(
sql,
"(estimated_year IS NOT NULL AND estimated_year NOT IN ($1))"
);
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_option_not_in_only_none_uses_is_not_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[None],
true,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NOT NULL");
}
#[test]
fn emit_option_not_in_only_some_unions_null_branch() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_in::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[Some(2019), Some(2020)],
true,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(
sql,
"(estimated_year IS NULL OR estimated_year NOT IN ($1, $2))"
);
assert_eq!(binds.len(), 2);
}
#[test]
fn emit_list_empty_in_returns_false() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.in_(vec![]));
let mut acc = SqlAccumulator::new("");
emit::emit_list::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", &pred, false)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "FALSE");
assert!(binds.is_empty());
}
#[test]
fn emit_list_empty_not_in_returns_true() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.not_in(vec![]));
let mut acc = SqlAccumulator::new("");
emit::emit_list::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", &pred, true)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "TRUE");
}
#[test]
fn emit_list_non_empty_in_emits_inlist() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.in_(vec![1, 2, 3]));
let mut acc = SqlAccumulator::new("");
emit::emit_list::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", &pred, false)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "score IN ($1, $2, $3)");
assert_eq!(binds.len(), 3);
}
#[test]
fn emit_list_non_empty_not_in_emits_not_inlist() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.not_in(vec![1, 2]));
let mut acc = SqlAccumulator::new("");
emit::emit_list::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", &pred, true)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "score NOT IN ($1, $2)");
assert_eq!(binds.len(), 2);
}
#[test]
fn emit_list_value_type_mismatch_returns_typed_error() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.in_(vec![1]));
let mut acc = SqlAccumulator::new("");
let result = emit::emit_list::<TestModel, String>(
&mut acc,
SqlEmitContext::root(),
"score",
&pred,
false,
);
match result {
Err(PortablePredicateError::ValueTypeMismatch { field, .. }) => {
assert_eq!(field, "score");
}
other => panic!("expected ValueTypeMismatch, got {other:?}"),
}
}
#[test]
fn emit_present_list_empty_in_returns_false() {
let mut acc = SqlAccumulator::new("");
emit::emit_present_list::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[],
false,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "FALSE");
assert!(binds.is_empty());
}
#[test]
fn emit_present_list_empty_not_in_requires_present_value() {
let mut acc = SqlAccumulator::new("");
emit::emit_present_list::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[],
true,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NOT NULL");
assert!(binds.is_empty());
}
#[test]
fn emit_present_list_non_empty_not_in_excludes_nulls() {
let mut acc = SqlAccumulator::new("");
emit::emit_present_list::<i32>(
&mut acc,
SqlEmitContext::root(),
"estimated_year",
&[2019, 2020],
true,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(
sql,
"(estimated_year IS NOT NULL AND estimated_year NOT IN ($1, $2))"
);
assert_eq!(binds.len(), 2);
}
#[test]
fn emit_string_pattern_contains_wraps_bind_with_percent_signs() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.contains("rust"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::root(),
"name",
emit::PatternOp::Contains,
&pred,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "name LIKE $1 ESCAPE '\\'");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_string_pattern_icontains_uses_collate_c_ilike() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.icontains("rust"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::root(),
"name",
emit::PatternOp::IContains,
&pred,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "name COLLATE \"C\" ILIKE $1 ESCAPE '\\'");
}
#[test]
fn emit_string_pattern_iexact_uses_no_wildcard_collate_ilike() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.iexact("Rust"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::root(),
"name",
emit::PatternOp::IExact,
&pred,
)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "name COLLATE \"C\" ILIKE $1 ESCAPE '\\'");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_string_pattern_starts_with_appends_percent() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.starts_with("ru"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::root(),
"name",
emit::PatternOp::StartsWith,
&pred,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "name LIKE $1 ESCAPE '\\'");
}
#[test]
fn emit_string_pattern_ends_with_prepends_percent() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.ends_with("st"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::root(),
"name",
emit::PatternOp::EndsWith,
&pred,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "name LIKE $1 ESCAPE '\\'");
}
#[test]
fn emit_value_emits_op_and_bind() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.eq(42));
let mut acc = SqlAccumulator::new("");
emit::emit_value::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", " = ", &pred)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "score = $1");
assert_eq!(binds.len(), 1);
}
#[test]
fn emit_pair_emits_between_with_two_binds() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.between(0, 100));
let mut acc = SqlAccumulator::new("");
emit::emit_pair::<TestModel, i32>(&mut acc, SqlEmitContext::root(), "score", &pred)
.unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "score BETWEEN $1 AND $2");
assert_eq!(binds.len(), 2);
}
#[test]
fn emit_null_true_uses_is_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_null(&mut acc, SqlEmitContext::root(), "estimated_year", true).unwrap();
let (sql, binds) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NULL");
assert!(binds.is_empty());
}
#[test]
fn emit_null_false_uses_is_not_null() {
let mut acc = SqlAccumulator::new("");
emit::emit_null(&mut acc, SqlEmitContext::root(), "estimated_year", false).unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "estimated_year IS NOT NULL");
}
#[test]
fn emit_value_under_joined_context_qualifies_column() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred = unwrap_field_pred(f.eq(42));
let mut acc = SqlAccumulator::new("");
emit::emit_value::<TestModel, i32>(
&mut acc,
SqlEmitContext::joined("test_models"),
"score",
" = ",
&pred,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "test_models.score = $1");
}
#[test]
fn emit_string_pattern_under_joined_context_qualifies_column() {
let f = SassiField::<TestModel, String>::new("name", |m| &m.name);
let pred = unwrap_field_pred(f.icontains("rust"));
let mut acc = SqlAccumulator::new("");
emit::emit_string_pattern(
&mut acc,
SqlEmitContext::joined("test_models"),
"name",
emit::PatternOp::IContains,
&pred,
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(sql, "test_models.name COLLATE \"C\" ILIKE $1 ESCAPE '\\'");
}
#[test]
fn emit_option_eq_under_joined_context_qualifies_both_sides() {
let mut acc = SqlAccumulator::new("");
emit::emit_option_neq::<i32>(
&mut acc,
SqlEmitContext::joined("test_models"),
"estimated_year",
&Some(2020),
)
.unwrap();
let (sql, _) = acc.into_parts();
assert_eq!(
sql,
"(test_models.estimated_year IS NULL OR test_models.estimated_year <> $1)"
);
}
#[test]
fn pr2d_joined_push_column_with_dotted_path_emits_as_is() {
let mut acc = SqlAccumulator::new("");
SqlEmitContext::joined("posts").push_column(&mut acc, "rel.field");
let (sql, _) = acc.into_parts();
assert_eq!(sql, "rel.field");
}
fn forged_jsahibon_extractor(m: &TestModel) -> &sassi::JSahibON {
&m.payload
}
#[test]
fn forged_raw_sassi_json_predicate_is_rejected_when_untrusted() {
let forged: BasicPredicate<TestModel> = SassiField::<TestModel, sassi::JSahibON>::new(
"forged_payload",
forged_jsahibon_extractor,
)
.jsahibon()
.exists();
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&forged,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
match result {
Err(PortablePredicateError::UntrustedJsonPredicate { field }) => {
assert_eq!(field, "forged_payload");
}
other => panic!("expected UntrustedJsonPredicate, got {other:?}"),
}
}
#[test]
fn forged_json_predicate_nested_in_and_is_rejected() {
let forged_json: BasicPredicate<TestModel> = SassiField::<TestModel, sassi::JSahibON>::new(
"forged_payload",
forged_jsahibon_extractor,
)
.jsahibon()
.exists();
let nested = BasicPredicate::And(vec![BasicPredicate::True, forged_json]);
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&nested,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
match result {
Err(PortablePredicateError::UntrustedJsonPredicate { field }) => {
assert_eq!(field, "forged_payload");
}
other => panic!("expected UntrustedJsonPredicate, got {other:?}"),
}
}
#[test]
fn forged_json_predicate_nested_in_or_is_rejected() {
let forged_json: BasicPredicate<TestModel> = SassiField::<TestModel, sassi::JSahibON>::new(
"forged_payload",
forged_jsahibon_extractor,
)
.jsahibon()
.exists();
let nested = BasicPredicate::Or(vec![BasicPredicate::False, forged_json]);
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&nested,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
assert!(
matches!(
result,
Err(PortablePredicateError::UntrustedJsonPredicate { field }) if field == "forged_payload"
),
"expected UntrustedJsonPredicate(forged_payload), got {result:?}",
);
}
#[test]
fn forged_json_predicate_nested_in_not_is_rejected() {
let forged_json: BasicPredicate<TestModel> = SassiField::<TestModel, sassi::JSahibON>::new(
"forged_payload",
forged_jsahibon_extractor,
)
.jsahibon()
.exists();
let nested = BasicPredicate::Not(Box::new(forged_json));
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&nested,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
assert!(matches!(
result,
Err(PortablePredicateError::UntrustedJsonPredicate { .. })
));
}
#[test]
fn forged_json_predicate_nested_in_xor_is_rejected() {
let forged_json: BasicPredicate<TestModel> = SassiField::<TestModel, sassi::JSahibON>::new(
"forged_payload",
forged_jsahibon_extractor,
)
.jsahibon()
.exists();
let nested = BasicPredicate::Xor(Box::new(BasicPredicate::True), Box::new(forged_json));
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&nested,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
assert!(matches!(
result,
Err(PortablePredicateError::UntrustedJsonPredicate { .. })
));
}
#[test]
fn untrusted_non_json_field_predicate_dispatches_normally() {
let f = SassiField::<TestModel, i32>::new("score", |m| &m.score);
let pred: BasicPredicate<TestModel> = f.eq(42);
let mut acc = SqlAccumulator::new("");
let result = emit_basic_predicate::<TestModel>(
&mut acc,
&pred,
SqlEmitContext::root(),
JsonTrust::Untrusted,
);
match result {
Err(PortablePredicateError::UnsupportedModel { .. }) => {}
other => panic!("expected UnsupportedModel from non-JSON dispatch, got {other:?}"),
}
}
#[test]
fn jsontrust_variants_are_distinct() {
assert_ne!(JsonTrust::Trusted, JsonTrust::Untrusted);
assert_eq!(JsonTrust::Trusted, JsonTrust::Trusted);
assert_eq!(JsonTrust::Untrusted, JsonTrust::Untrusted);
}
}