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