1#[cfg(feature = "resources")]
4mod resources;
5
6use sofie_core::{Error, FieldError, Instant, Result, SqlType, SqlValue};
7use sofie_values::{EXACT, ORDERED, Operator, TEXT};
8
9#[cfg(feature = "resources")]
10pub use self::resources::delete_softly;
11use crate::aggregate::field_columns;
12use crate::condition::Kind;
13use crate::{Aggregate, Condition, Field, Sort, Source};
14
15#[must_use]
16pub fn filterable<A: Aggregate>(name: &str) -> Vec<Operator> {
17 let Some(column) = column_of::<A>(name) else {
18 return Vec::new();
19 };
20 let mut offered = operators(column.ty).to_vec();
21 if column.nullable && !offered.is_empty() {
22 offered.push(Operator::IsEmpty);
23 }
24 offered
25}
26
27#[must_use]
28pub fn searched<A: Aggregate>() -> bool {
29 cfg!(feature = "search") && A::BEHAVIORS.iter().any(|behavior| behavior.searched())
30}
31
32#[must_use]
34pub fn selects<S: Source>(name: &str) -> bool {
35 let wanted = if name == "id" {
36 vec!["id".to_owned()]
37 } else {
38 match listed_field::<S::Base>(name) {
39 Some(field) => field_columns(std::slice::from_ref(field)),
40 None => return false,
41 }
42 };
43 let selected = S::columns();
44 wanted.iter().all(|column| {
45 selected
46 .iter()
47 .any(|selection| selection.column_name() == Some(column))
48 })
49}
50
51fn listed_field<A: Aggregate>(name: &str) -> Option<&'static Field> {
52 if A::SENSITIVE.contains(&name) {
53 return None;
54 }
55 A::FIELDS.iter().find(|field| field.name == name)
56}
57
58struct FieldColumn {
59 name: String,
60 ty: SqlType,
61 nullable: bool,
62 values: &'static [&'static str],
63 parse_text: fn(&str) -> Option<Result<SqlValue, FieldError>>,
64}
65
66fn operators(ty: SqlType) -> &'static [Operator] {
67 match ty {
68 SqlType::Text => TEXT,
69 SqlType::Int | SqlType::Float | SqlType::Timestamp | SqlType::Date | SqlType::DateTime => {
70 ORDERED
71 }
72 SqlType::Bool | SqlType::Uuid => EXACT,
73 SqlType::Bytes => &[],
74 }
75}
76
77fn column_of<A: Aggregate>(name: &str) -> Option<FieldColumn> {
78 if name == "id" {
79 return Some(FieldColumn {
80 name: "id".to_owned(),
81 ty: SqlType::Uuid,
82 nullable: false,
83 values: &[],
84 parse_text: |_| None,
85 });
86 }
87 let field = listed_field::<A>(name)?;
88 match field.columns {
89 [column] => Some(FieldColumn {
90 name: crate::sql::column_name(name, column),
91 ty: column.ty,
92 nullable: field.nullable,
93 values: column.values,
94 parse_text: field.parse_text,
95 }),
96 _ => None,
97 }
98}
99
100impl<A: Aggregate> Sort<A> {
101 #[must_use]
102 pub fn by_field(name: &str, descending: bool) -> Option<Self> {
103 if let Some(column) = column_of::<A>(name) {
104 return Some(Self::new(column.name, descending, column.nullable));
105 }
106 let field = listed_field::<A>(name)?;
107 let mut ordering = field.columns.iter().filter(|column| column.orders);
108 match (ordering.next(), ordering.next()) {
109 (Some(column), None) => Some(Self::new(
110 crate::sql::column_name(name, column),
111 descending,
112 field.nullable,
113 )),
114 _ => None,
115 }
116 }
117}
118
119impl<A: Aggregate> Condition<A> {
120 #[cfg_attr(not(feature = "search"), expect(unused_variables))]
121 #[must_use]
122 pub fn searching(words: &str) -> Option<Self> {
123 #[cfg(feature = "search")]
124 if searched::<A>() {
125 return Some(crate::search::text_search(words));
126 }
127 None
128 }
129
130 pub fn on_field(name: &str, operator: Operator, text: &str) -> Result<Self> {
132 let refused = |why: String| {
133 Error::BadRequest(format!("a list cannot be filtered by `{name}`: {why}"))
134 };
135 if !filterable::<A>(name).contains(&operator) {
136 return Err(refused(format!("it offers no `{operator:?}`")));
137 }
138 let column = column_of::<A>(name).ok_or_else(|| refused("no such field".to_owned()))?;
139 let exact = matches!(operator, Operator::Equals | Operator::NotEquals);
140 if exact && !column.values.is_empty() && !column.values.contains(&text) {
141 let accepted = column.values.join(", ");
142 let refusal = FieldError::new(
143 name,
144 "validation.one_of",
145 format!("must be one of: {accepted}"),
146 );
147 return Err(Error::Invalid(vec![refusal.with("values", accepted)]));
148 }
149 let value = |text: &str| column.value(name, text);
150 let compare = |operator: &'static str, value: SqlValue| Kind::Compare {
151 columns: vec![column.name.clone()],
152 operator,
153 values: vec![value],
154 };
155 let kind = match operator {
156 Operator::Equals => compare("=", value(text)?),
157 Operator::NotEquals if column.nullable => {
158 Kind::Not(Box::new(compare("IS NOT DISTINCT FROM", value(text)?)))
159 }
160 Operator::NotEquals => Kind::Not(Box::new(compare("=", value(text)?))),
161 Operator::GreaterThan => compare(">", value(text)?),
162 Operator::LessThan => compare("<", value(text)?),
163 Operator::AtLeast => compare(">=", value(text)?),
164 Operator::AtMost => compare("<=", value(text)?),
165 Operator::Between => {
166 let (low, high) = text.split_once("..").ok_or_else(|| {
167 let refusal = FieldError::new(
168 name,
169 "validation.range",
170 format!("`{text}` is not a range `low..high`"),
171 );
172 Error::Invalid(vec![refusal.with("value", text)])
173 })?;
174 Kind::Between {
175 column: column.name.clone(),
176 low: value(low)?,
177 high: value(high)?,
178 }
179 }
180 Operator::Contains => Kind::Contains {
181 column: column.name.clone(),
182 pattern: contains_pattern(text),
183 },
184 Operator::IsEmpty => Kind::Null(vec![column.name.clone()]),
185 };
186 Ok(Self::new(kind))
187 }
188}
189
190impl FieldColumn {
191 fn value(&self, name: &str, text: &str) -> Result<SqlValue> {
192 match (self.parse_text)(text) {
193 Some(read) => read.map_err(|refusal| refused(name, vec![refusal])),
194 None if self.ty == SqlType::Timestamp => instant_value(name, text),
195 None => parse(self.ty, text)
196 .ok_or_else(|| refused(name, vec![crate::aggregate::expected(self.ty)])),
197 }
198 }
199}
200
201fn refused(name: &str, refusals: Vec<FieldError>) -> Error {
202 let named = refusals.into_iter().map(|refusal| FieldError {
203 field: name.to_owned(),
204 ..refusal
205 });
206 Error::Invalid(named.collect())
207}
208
209fn parse(ty: SqlType, text: &str) -> Option<SqlValue> {
210 Some(match ty {
211 SqlType::Text => SqlValue::Text(text.to_owned()),
212 SqlType::Int => SqlValue::Int(text.parse().ok()?),
213 SqlType::Float => SqlValue::Float(text.parse().ok()?),
214 SqlType::Bool => SqlValue::Bool(text.parse().ok()?),
215 SqlType::Uuid => SqlValue::Uuid(text.parse().ok()?),
216 SqlType::Date => SqlValue::Date(text.parse().ok()?),
217 SqlType::DateTime => SqlValue::DateTime(text.parse().ok()?),
218 SqlType::Timestamp | SqlType::Bytes => return None,
219 })
220}
221
222fn instant_value(name: &str, text: &str) -> Result<SqlValue> {
224 let instant = Instant::parse(text).map_err(|error| match error {
225 Error::Invalid(refusals) => refused(name, refusals),
226 other => other,
227 })?;
228 Ok(SqlValue::Timestamp(instant.timestamp()))
229}
230
231fn contains_pattern(text: &str) -> String {
233 let mut pattern = String::with_capacity(text.len() + 4);
234 pattern.push('%');
235 for character in text.chars() {
236 if matches!(character, '\\' | '%' | '_') {
237 pattern.push('\\');
238 }
239 pattern.push(character);
240 }
241 pattern.push('%');
242 pattern
243}
244
245#[cfg(test)]
246mod tests {
247 use std::marker::PhantomData;
248
249 use sofie_core::{Cast as _, ColumnSpec};
250
251 use super::*;
252 use crate::{ReadsNoText as _, ReadsText as _};
253
254 #[test]
255 fn a_filter_reads_its_value_as_the_column_s_type() {
256 assert_eq!(parse(SqlType::Int, "12"), Some(SqlValue::Int(12)));
257 assert_eq!(parse(SqlType::Int, "twelve"), None);
258 assert_eq!(parse(SqlType::Bool, "true"), Some(SqlValue::Bool(true)));
259 assert_eq!(contains_pattern("50%_\\"), "%50\\%\\_\\\\%");
260 }
261
262 #[test]
263 fn an_instant_filter_reads_a_datetime_local_value_as_an_instant_does() {
264 let instant = sofie_core::Instant::parse("2026-10-16T10:00").unwrap();
265 assert_eq!(
266 instant_value("due", "2026-10-16T10:00").unwrap(),
267 SqlValue::Timestamp(instant.timestamp())
268 );
269 }
270
271 struct Priced(crate::Meta);
272
273 const AMOUNT: &[ColumnSpec] = &[
274 ColumnSpec::new("", SqlType::Int),
275 ColumnSpec::new("_currency", SqlType::Text).unordered(),
276 ];
277
278 impl Aggregate for Priced {
279 const NAME: &'static str = "priced";
280 const MODULE: &'static str = "shop";
281 const FIELDS: &'static [crate::Field] = &[
282 crate::Field::new("total", AMOUNT, false),
283 crate::Field::new("token", &[ColumnSpec::new("", SqlType::Text)], false),
284 crate::Field::new("note", &[ColumnSpec::new("", SqlType::Text)], true),
285 crate::Field::new("due", &[ColumnSpec::new("", SqlType::Timestamp)], false),
286 ];
287 const SENSITIVE: &'static [&'static str] = &["token"];
288
289 fn meta(&self) -> &crate::Meta {
290 &self.0
291 }
292
293 fn meta_mut(&mut self) -> &mut crate::Meta {
294 &mut self.0
295 }
296
297 fn encode(&self, _out: &mut Vec<SqlValue>) {}
298
299 fn decode(meta: crate::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
300 Ok(Self(meta))
301 }
302 }
303
304 #[test]
305 fn a_value_of_several_columns_sorts_by_the_one_that_orders_it_and_filters_by_none() {
306 assert!(Sort::<Priced>::by_field("total", true).is_some());
307 assert_eq!(filterable::<Priced>("total"), []);
308 }
309
310 #[test]
311 fn a_sensitive_field_is_neither_filtered_nor_sorted_by() {
312 assert_eq!(filterable::<Priced>("token"), []);
313 assert!(Sort::<Priced>::by_field("token", false).is_none());
314 assert!(!selects::<Priced>("token"));
315 assert!(Condition::<Priced>::on_field("token", Operator::Equals, "a").is_err());
316 }
317
318 #[test]
319 fn a_nullable_field_unequal_to_a_value_keeps_its_empty_rows() {
320 let condition = Condition::<Priced>::on_field("note", Operator::NotEquals, "gift").unwrap();
321 let (mut sql, mut params) = (String::new(), Vec::new());
322 condition
323 .kind
324 .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
325
326 assert_eq!(sql, "NOT (\"note\" IS NOT DISTINCT FROM $1)");
327 }
328
329 #[test]
330 fn an_instant_filter_refused_is_an_error_of_its_field_saying_why() {
331 let refused = Condition::<Priced>::on_field("due", Operator::AtLeast, "yesterday").err();
332
333 let Some(Error::Invalid(errors)) = &refused else {
334 panic!("{refused:?}");
335 };
336 assert_eq!(errors[0].field, "due");
337 assert!(errors[0].message.contains("with an offset"), "{refused:?}");
338 }
339
340 struct Tenths(i64);
341
342 struct Count(i64);
343
344 macro_rules! int_column {
345 ($ty:ident) => {
346 impl sofie_core::Cast for $ty {
347 const COLUMNS: &'static [ColumnSpec] = &[ColumnSpec::new("", SqlType::Int)];
348
349 fn encode(&self, out: &mut Vec<SqlValue>) {
350 out.push(SqlValue::Int(self.0));
351 }
352
353 fn decode(_row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
354 Ok(Self(0))
355 }
356 }
357 };
358 }
359
360 int_column!(Tenths);
361 int_column!(Count);
362 int_column!(Hundredths);
363
364 struct Hundredths(i64);
365
366 impl<'de> serde::Deserialize<'de> for Hundredths {
367 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
368 let whole: i64 = String::deserialize(deserializer)?
369 .parse()
370 .map_err(serde::de::Error::custom)?;
371 if !(0..=100).contains(&whole) {
372 return Err(serde::de::Error::custom("must be at most 100"));
373 }
374 Ok(Self(whole * 100))
375 }
376 }
377
378 impl<'de> serde::Deserialize<'de> for Tenths {
379 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
380 let text = String::deserialize(deserializer)?;
381 let tenths = text
382 .replace('.', "")
383 .parse()
384 .map_err(serde::de::Error::custom)?;
385 Ok(Self(tenths))
386 }
387 }
388
389 struct Rated(crate::Meta);
390
391 impl Aggregate for Rated {
392 const NAME: &'static str = "rated";
393 const MODULE: &'static str = "shop";
394 #[expect(
395 clippy::needless_borrow,
396 reason = "as the derive writes it, for every type"
397 )]
398 const FIELDS: &'static [crate::Field] = &[
399 crate::Field::new("rate", Tenths::COLUMNS, false)
400 .parsed_by(|text| (&&crate::TextOf::<Tenths>(PhantomData)).read(text)),
401 crate::Field::new("count", Count::COLUMNS, false)
402 .parsed_by(|text| (&&crate::TextOf::<Count>(PhantomData)).read(text)),
403 crate::Field::new("total", i64::COLUMNS, false)
404 .parsed_by(|text| (&&crate::TextOf::<i64>(PhantomData)).read(text)),
405 crate::Field::new("share", Hundredths::COLUMNS, false)
406 .parsed_by(|text| (&&crate::TextOf::<Hundredths>(PhantomData)).read(text)),
407 crate::Field::new("open", bool::COLUMNS, false)
408 .parsed_by(|text| (&&crate::TextOf::<bool>(PhantomData)).read(text)),
409 crate::Field::new("due", Instant::COLUMNS, false)
410 .parsed_by(|text| (&&crate::TextOf::<Instant>(PhantomData)).read(text)),
411 crate::Field::new("owner", sofie_core::Uuid::COLUMNS, false)
412 .parsed_by(|text| (&&crate::TextOf::<sofie_core::Uuid>(PhantomData)).read(text)),
413 ];
414
415 fn meta(&self) -> &crate::Meta {
416 &self.0
417 }
418
419 fn meta_mut(&mut self) -> &mut crate::Meta {
420 &mut self.0
421 }
422
423 fn encode(&self, _out: &mut Vec<SqlValue>) {}
424
425 fn decode(meta: crate::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
426 Ok(Self(meta))
427 }
428 }
429
430 fn filtered_values(name: &str, text: &str) -> Vec<SqlValue> {
431 let condition = Condition::<Rated>::on_field(name, Operator::AtLeast, text).unwrap();
432 let (mut sql, mut params) = (String::new(), Vec::new());
433 condition
434 .kind
435 .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
436 params
437 }
438
439 #[test]
440 fn a_filter_reads_its_value_as_the_field_s_type_reads_text() {
441 assert_eq!(filtered_values("rate", "1.9"), [SqlValue::Int(19)]);
442 assert_eq!(filtered_values("count", "12"), [SqlValue::Int(12)]);
443 assert_eq!(filtered_values("total", "12"), [SqlValue::Int(12)]);
444 assert_eq!(filtered_values("share", "19"), [SqlValue::Int(1900)]);
445 }
446
447 fn refusal_of<A: Aggregate>(name: &str, operator: Operator, text: &str) -> String {
448 refused_by::<A>(name, operator, text).message
449 }
450
451 fn refused_by<A: Aggregate>(name: &str, operator: Operator, text: &str) -> FieldError {
452 match Condition::<A>::on_field(name, operator, text).err() {
453 Some(Error::Invalid(mut errors)) if errors.len() == 1 && errors[0].field == name => {
454 errors.remove(0)
455 }
456 other => panic!("`{name}` refusing `{text}` gave {other:?}"),
457 }
458 }
459
460 #[test]
461 fn a_value_the_field_s_type_refuses_is_not_read_in_the_column_s_units() {
462 let refused = refusal_of::<Rated>("share", Operator::AtLeast, "150");
463 assert!(refused.contains("at most 100"), "{refused}");
464 refusal_of::<Rated>("total", Operator::AtLeast, "twelve");
465 let open = Condition::<Rated>::on_field("open", Operator::Equals, "true").unwrap();
466 let (mut sql, mut params) = (String::new(), Vec::new());
467 open.kind
468 .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
469 assert_eq!(params, [SqlValue::Bool(true)]);
470 }
471
472 #[test]
473 fn every_filter_value_refused_is_an_error_of_its_field() {
474 refusal_of::<Rated>("due", Operator::Between, "2026-10-16T10:00");
475 refusal_of::<Rated>("open", Operator::Equals, "maybe");
476 refusal_of::<Priced>("due", Operator::Between, "a..b");
477 refusal_of::<Priced>("id", Operator::Equals, "not-a-uuid");
478 }
479
480 #[test]
481 fn a_value_not_of_its_column_s_kind_is_refused_in_neutral_words() {
482 let rated = |name, text| refusal_of::<Rated>(name, Operator::Equals, text);
483
484 assert_eq!(rated("total", "twelve"), "must be a whole number");
485 assert_eq!(rated("total", "1.5"), "must be a whole number");
486 assert_eq!(rated("open", "maybe"), "must be true or false");
487 assert_eq!(rated("owner", "not-a-uuid"), "must be an id");
488 assert_eq!(rated("owner", "7"), "must be an id");
489 assert_eq!(
490 refusal_of::<Priced>("id", Operator::Equals, "7"),
491 "must be an id"
492 );
493 }
494
495 #[test]
496 fn a_refusal_carries_the_key_and_arguments_a_page_translates_it_by() {
497 let rated = |name, operator, text| refused_by::<Rated>(name, operator, text);
498
499 assert_eq!(
500 rated("total", Operator::Equals, "twelve").key,
501 "validation.kind.whole_number"
502 );
503 assert_eq!(
504 rated("open", Operator::Equals, "maybe").key,
505 "validation.kind.true_or_false"
506 );
507 assert_eq!(
508 rated("due", Operator::AtLeast, "yesterday").key,
509 "validation.instant.invalid"
510 );
511 let range = rated("total", Operator::Between, "12");
512 assert_eq!(range.key, "validation.range");
513 assert_eq!(range.arguments, [("value".into(), "12".to_owned())]);
514 let undated = refused_by::<Priced>("due", Operator::AtLeast, "yesterday");
515 assert_eq!(undated.key, "validation.instant.invalid");
516 }
517
518 #[test]
519 fn a_derived_instant_filter_refused_says_what_reading_the_instant_says() {
520 let refused = refusal_of::<Rated>("due", Operator::AtLeast, "yesterday");
521 let read = Instant::parse("yesterday").unwrap_err();
522 assert_eq!(refused, format!("{read:#}"));
523 }
524}