1use crate::config::{IncludeDirection, PkType, ResolvedEntity};
4use crate::db::{type_category_from_cast, CanonicalType, Dialect, TypeCategory};
5use crate::error::AppError;
6use crate::extensible_fields::ExtensibleRegistry;
7use crate::sql::rsql::{FilterNode, RsqlOp, SortSpec};
8use serde_json::Value;
9use std::collections::HashMap;
10
11pub struct IncludeSelect<'a> {
13 pub name: &'a str,
14 pub direction: IncludeDirection,
15 pub related: &'a ResolvedEntity,
16 pub our_key: &'a str,
17 pub their_key: &'a str,
18}
19
20fn quoted(s: &str) -> String {
22 format!("\"{}\"", s.replace('"', "\"\""))
23}
24
25fn qualified_table(schema: &str, table: &str) -> String {
27 format!("{}.{}", quoted(schema), quoted(table))
28}
29
30pub struct QueryBuf {
31 pub sql: String,
32 pub params: Vec<Value>,
33}
34
35impl QueryBuf {
36 fn new() -> Self {
37 QueryBuf {
38 sql: String::new(),
39 params: Vec::new(),
40 }
41 }
42
43 fn push_param(&mut self, v: Value) -> u32 {
44 let n = self.params.len() as u32 + 1;
45 self.params.push(v);
46 n
47 }
48}
49
50fn select_column_list(entity: &ResolvedEntity) -> String {
53 entity
54 .columns
55 .iter()
56 .map(|c| {
57 let q = quoted(&c.name);
58 let pg_type = c.pg_type.as_deref().unwrap_or("");
59 if pg_type.contains('.')
60 || pg_type == "numeric"
61 || pg_type == "time"
62 || pg_type == "timetz"
63 {
64 format!("{}::text", q)
65 } else {
66 q
67 }
68 })
69 .collect::<Vec<_>>()
70 .join(", ")
71}
72
73fn resolve_schema<'a>(entity: &'a ResolvedEntity, schema_override: Option<&'a str>) -> &'a str {
75 schema_override.unwrap_or(&entity.schema_name)
76}
77
78pub fn coerce_json_value_for_pg_array(val: Value, pg_type: Option<&str>) -> Value {
80 if !pg_type.is_some_and(|t| t.ends_with("[]")) {
81 return val;
82 }
83 match val {
84 Value::Null => Value::Null,
85 Value::Array(items) => {
86 let mut out = String::from('{');
87 for (i, v) in items.iter().enumerate() {
88 if i > 0 {
89 out.push(',');
90 }
91 match v {
92 Value::Null => out.push_str("NULL"),
93 other => {
94 let elem = match other {
95 Value::String(s) => s.clone(),
96 Value::Number(n) => n.to_string(),
97 Value::Bool(b) => b.to_string(),
98 _ => serde_json::to_string(other).unwrap_or_else(|_| "{}".to_string()),
99 };
100 out.push('"');
101 for ch in elem.chars() {
102 if ch == '"' || ch == '\\' {
103 out.push('\\');
104 }
105 out.push(ch);
106 }
107 out.push('"');
108 }
109 }
110 }
111 out.push('}');
112 Value::String(out)
113 }
114 Value::String(s) => {
121 let items: Vec<Value> = s
122 .split(',')
123 .map(|part| part.trim())
124 .filter(|part| !part.is_empty())
125 .map(|part| Value::String(part.to_string()))
126 .collect();
127 coerce_json_value_for_pg_array(Value::Array(items), pg_type)
128 }
129 other => coerce_json_value_for_pg_array(Value::Array(vec![other]), pg_type),
131 }
132}
133
134fn pk_placeholder(entity: &ResolvedEntity, param_num: usize, dialect: &dyn Dialect) -> String {
138 let ph = dialect.placeholder(param_num);
139 let canonical = match &entity.pk_type {
140 PkType::Uuid => crate::db::CanonicalType::Uuid,
141 PkType::BigInt => crate::db::CanonicalType::BigInt,
142 PkType::Int => crate::db::CanonicalType::Int,
143 PkType::Text => return ph,
144 };
145 match dialect.cast_name(&canonical) {
146 Some(cast) => dialect.cast_expr(&ph, &cast),
147 None => ph,
148 }
149}
150
151fn op_valid_for_category(op: &RsqlOp, category: TypeCategory) -> bool {
154 match category {
155 TypeCategory::Text => matches!(
156 op,
157 RsqlOp::Eq
158 | RsqlOp::Neq
159 | RsqlOp::In
160 | RsqlOp::Out
161 | RsqlOp::Like
162 | RsqlOp::Ilike
163 | RsqlOp::Contains
164 | RsqlOp::Starts
165 | RsqlOp::Ends
166 | RsqlOp::Null(_)
167 ),
168 TypeCategory::Int | TypeCategory::Float => matches!(
169 op,
170 RsqlOp::Eq
171 | RsqlOp::Neq
172 | RsqlOp::Gt
173 | RsqlOp::Ge
174 | RsqlOp::Lt
175 | RsqlOp::Le
176 | RsqlOp::Between
177 | RsqlOp::In
178 | RsqlOp::Out
179 | RsqlOp::Null(_)
180 ),
181 TypeCategory::Bool => matches!(op, RsqlOp::Eq | RsqlOp::Neq | RsqlOp::Null(_)),
182 TypeCategory::Uuid => matches!(
183 op,
184 RsqlOp::Eq | RsqlOp::Neq | RsqlOp::In | RsqlOp::Out | RsqlOp::Null(_)
185 ),
186 TypeCategory::Date | TypeCategory::Timestamp | TypeCategory::Time => matches!(
187 op,
188 RsqlOp::Eq
189 | RsqlOp::Neq
190 | RsqlOp::Gt
191 | RsqlOp::Ge
192 | RsqlOp::Lt
193 | RsqlOp::Le
194 | RsqlOp::Between
195 | RsqlOp::In
196 | RsqlOp::Out
197 | RsqlOp::Null(_)
198 ),
199 TypeCategory::Json | TypeCategory::Bytes | TypeCategory::Other => true,
201 }
202}
203
204fn canonical_cast_str(t: &CanonicalType) -> Option<&'static str> {
209 match t {
210 CanonicalType::SmallInt => Some("smallint"),
211 CanonicalType::Int | CanonicalType::Serial => Some("integer"),
212 CanonicalType::BigInt | CanonicalType::BigSerial => Some("bigint"),
213 CanonicalType::Real => Some("real"),
214 CanonicalType::Double => Some("double precision"),
215 CanonicalType::Decimal(_) => Some("numeric"),
216 CanonicalType::Boolean => Some("boolean"),
217 CanonicalType::Uuid => Some("uuid"),
218 CanonicalType::Json | CanonicalType::Jsonb => Some("jsonb"),
219 CanonicalType::Timestamp => Some("timestamptz"),
220 CanonicalType::TimestampNtz => Some("timestamp"),
221 CanonicalType::Date => Some("date"),
222 CanonicalType::Time => Some("time"),
223 CanonicalType::Timetz => Some("timetz"),
224 _ => None,
225 }
226}
227
228fn make_placeholder(n: usize, cast: Option<&str>, dialect: &dyn Dialect) -> String {
229 let ph = dialect.placeholder(n);
230 match cast {
231 Some(t) => dialect.cast_expr(&ph, t),
232 None => ph,
233 }
234}
235
236fn build_leaf_sql(
241 qcol: &str,
242 pg_type: Option<&str>,
243 op: &RsqlOp,
244 values: &[String],
245 q: &mut QueryBuf,
246 field_label: &str,
247 dialect: &dyn Dialect,
248) -> Result<String, AppError> {
249 let category = type_category_from_cast(pg_type.unwrap_or("text"));
250 if !op_valid_for_category(op, category) {
251 return Err(AppError::Validation(format!(
252 "operator {} is not valid for {:?} field '{}' (type: {})",
253 op.display(),
254 category,
255 field_label,
256 pg_type.unwrap_or("text")
257 )));
258 }
259 let cast = if matches!(
260 op,
261 RsqlOp::Like | RsqlOp::Ilike | RsqlOp::Contains | RsqlOp::Starts | RsqlOp::Ends
262 ) {
263 None
264 } else {
265 pg_type
266 };
267 match op {
268 RsqlOp::Null(is_null) => Ok(if *is_null {
269 format!("{} IS NULL", qcol)
270 } else {
271 format!("{} IS NOT NULL", qcol)
272 }),
273 RsqlOp::Eq | RsqlOp::Neq | RsqlOp::Gt | RsqlOp::Ge | RsqlOp::Lt | RsqlOp::Le => {
274 let v = values.first().cloned().unwrap_or_default();
275 let n = q.push_param(Value::String(v));
276 let ph = make_placeholder(n as usize, cast, dialect);
277 let cmp = match op {
278 RsqlOp::Eq => "=",
279 RsqlOp::Neq => "!=",
280 RsqlOp::Gt => ">",
281 RsqlOp::Ge => ">=",
282 RsqlOp::Lt => "<",
283 RsqlOp::Le => "<=",
284 _ => unreachable!(),
285 };
286 Ok(format!("{} {} {}", qcol, cmp, ph))
287 }
288 RsqlOp::Like => {
289 let v = values.first().cloned().unwrap_or_default();
290 let n = q.push_param(Value::String(v));
291 Ok(format!("{} LIKE {}", qcol, dialect.placeholder(n as usize)))
292 }
293 RsqlOp::Ilike => {
294 let v = values.first().cloned().unwrap_or_default();
295 let n = q.push_param(Value::String(v));
296 let ph = dialect.placeholder(n as usize);
297 Ok(dialect.case_insensitive_like(qcol, &ph))
298 }
299 RsqlOp::Contains => {
300 let v = values.first().cloned().unwrap_or_default();
301 let n = q.push_param(Value::String(format!("%{}%", v)));
302 let ph = dialect.placeholder(n as usize);
303 Ok(dialect.case_insensitive_like(qcol, &ph))
304 }
305 RsqlOp::Starts => {
306 let v = values.first().cloned().unwrap_or_default();
307 let n = q.push_param(Value::String(format!("{}%", v)));
308 let ph = dialect.placeholder(n as usize);
309 Ok(dialect.case_insensitive_like(qcol, &ph))
310 }
311 RsqlOp::Ends => {
312 let v = values.first().cloned().unwrap_or_default();
313 let n = q.push_param(Value::String(format!("%{}", v)));
314 let ph = dialect.placeholder(n as usize);
315 Ok(dialect.case_insensitive_like(qcol, &ph))
316 }
317 RsqlOp::In => {
318 if values.is_empty() {
319 return Err(AppError::Validation(format!(
320 "=in= requires at least one value for field '{}'",
321 field_label
322 )));
323 }
324 let phs: Vec<String> = values
325 .iter()
326 .map(|v| {
327 let n = q.push_param(Value::String(v.clone()));
328 make_placeholder(n as usize, cast, dialect)
329 })
330 .collect();
331 Ok(format!("{} IN ({})", qcol, phs.join(", ")))
332 }
333 RsqlOp::Out => {
334 if values.is_empty() {
335 return Err(AppError::Validation(format!(
336 "=out= requires at least one value for field '{}'",
337 field_label
338 )));
339 }
340 let phs: Vec<String> = values
341 .iter()
342 .map(|v| {
343 let n = q.push_param(Value::String(v.clone()));
344 make_placeholder(n as usize, cast, dialect)
345 })
346 .collect();
347 Ok(format!("{} NOT IN ({})", qcol, phs.join(", ")))
348 }
349 RsqlOp::Between => {
350 if values.len() != 2 {
351 return Err(AppError::Validation(format!(
352 "=between= requires exactly 2 values for field '{}', got {}",
353 field_label,
354 values.len()
355 )));
356 }
357 let n1 = q.push_param(Value::String(values[0].clone()));
358 let n2 = q.push_param(Value::String(values[1].clone()));
359 Ok(format!(
360 "{} BETWEEN {} AND {}",
361 qcol,
362 make_placeholder(n1 as usize, cast, dialect),
363 make_placeholder(n2 as usize, cast, dialect)
364 ))
365 }
366 #[allow(unreachable_patterns)]
367 RsqlOp::Null(_) => unreachable!(),
368 }
369}
370
371#[allow(clippy::too_many_arguments)]
380pub fn rsql_to_sql(
381 node: &FilterNode,
382 entity: &ResolvedEntity,
383 q: &mut QueryBuf,
384 col_qualifier: Option<&str>,
385 filter_includes: &[IncludeSelect<'_>],
386 schema_override: Option<&str>,
387 dialect: &dyn Dialect,
388 registry: Option<&ExtensibleRegistry>,
389) -> Result<String, AppError> {
390 match node {
391 FilterNode::And(children) => {
392 let parts: Result<Vec<_>, _> = children
393 .iter()
394 .map(|c| {
395 rsql_to_sql(
396 c,
397 entity,
398 q,
399 col_qualifier,
400 filter_includes,
401 schema_override,
402 dialect,
403 registry,
404 )
405 })
406 .collect();
407 Ok(format!("({})", parts?.join(" AND ")))
408 }
409 FilterNode::Or(children) => {
410 let parts: Result<Vec<_>, _> = children
411 .iter()
412 .map(|c| {
413 rsql_to_sql(
414 c,
415 entity,
416 q,
417 col_qualifier,
418 filter_includes,
419 schema_override,
420 dialect,
421 registry,
422 )
423 })
424 .collect();
425 Ok(format!("({})", parts?.join(" OR ")))
426 }
427 FilterNode::Leaf { field, op, values } => {
428 if let Some(dot_pos) = field.find('.') {
431 let head = &field[..dot_pos];
432 let key = &field[dot_pos + 1..];
433
434 if entity.extensible_columns.iter().any(|c| c == head) {
435 let def = registry.and_then(|r| r.field(head, key)).ok_or_else(|| {
436 AppError::Validation(format!(
437 "unknown extensible field '{}' (not declared in the registry)",
438 field
439 ))
440 })?;
441 if !def.filterable {
442 return Err(AppError::Validation(format!(
443 "extensible field '{}' is not filterable",
444 field
445 )));
446 }
447 let canonical = def.canonical();
448 let cf_cast = canonical_cast_str(&canonical);
453 let base_col = match col_qualifier {
454 Some(pfx) => format!("{}{}", pfx, quoted(head)),
455 None => quoted(head),
456 };
457 let json_expr = dialect.json_extract_typed(&base_col, key, &canonical);
458 return build_leaf_sql(&json_expr, cf_cast, op, values, q, field, dialect);
459 }
460
461 let include_name = head;
462 let sub_field = key;
463
464 let inc = filter_includes
465 .iter()
466 .find(|i| i.name == include_name)
467 .ok_or_else(|| AppError::Validation(format!(
468 "filter on '{}': '{}' is not a known include — add it to the include= parameter or ensure the relationship is configured",
469 field, include_name
470 )))?;
471
472 let col_info = inc
473 .related
474 .columns
475 .iter()
476 .find(|c| c.name == sub_field)
477 .ok_or_else(|| {
478 AppError::Validation(format!(
479 "unknown filter field '{}' on related entity '{}'",
480 sub_field, include_name
481 ))
482 })?;
483
484 let rel_schema = schema_override.unwrap_or(inc.related.schema_name.as_str());
485 let rel_table = qualified_table(rel_schema, &inc.related.table_name);
486
487 let join_cond = match col_qualifier {
489 Some(pfx) => {
490 format!("{} = {}{}", quoted(inc.their_key), pfx, quoted(inc.our_key))
491 }
492 None => format!("{} = {}", quoted(inc.their_key), quoted(inc.our_key)),
493 };
494
495 let field_cond = build_leaf_sql(
496 "ed(sub_field),
497 col_info.pg_type.as_deref(),
498 op,
499 values,
500 q,
501 field,
502 dialect,
503 )?;
504
505 return Ok(format!(
506 "EXISTS (SELECT 1 FROM {} WHERE {} AND {})",
507 rel_table, join_cond, field_cond
508 ));
509 }
510
511 let col_info = entity
513 .columns
514 .iter()
515 .find(|c| c.name == *field)
516 .ok_or_else(|| AppError::Validation(format!("unknown filter field '{}'", field)))?;
517
518 let qcol = match col_qualifier {
519 Some(pfx) => format!("{}{}", pfx, quoted(field)),
520 None => quoted(field),
521 };
522
523 build_leaf_sql(
524 &qcol,
525 col_info.pg_type.as_deref(),
526 op,
527 values,
528 q,
529 field,
530 dialect,
531 )
532 }
533 }
534}
535
536fn build_order_by(
543 sort: &[SortSpec],
544 entity: &ResolvedEntity,
545 col_qualifier: Option<&str>,
546 dialect: &dyn Dialect,
547 registry: Option<&ExtensibleRegistry>,
548) -> Result<String, AppError> {
549 let pk = &entity.pk_columns[0];
550 let col_names: std::collections::HashSet<&str> =
551 entity.columns.iter().map(|c| c.name.as_str()).collect();
552
553 let dir = |desc: bool| if desc { "DESC" } else { "ASC" };
554 let qualify = |name: &str| match col_qualifier {
555 Some(pfx) => format!("{}{}", pfx, quoted(name)),
556 None => quoted(name),
557 };
558
559 let mut parts: Vec<String> = Vec::new();
560 for s in sort {
561 if let Some(dot_pos) = s.field.find('.') {
563 let head = &s.field[..dot_pos];
564 let key = &s.field[dot_pos + 1..];
565 if entity.extensible_columns.iter().any(|c| c == head) {
566 let def = registry.and_then(|r| r.field(head, key)).ok_or_else(|| {
567 AppError::Validation(format!(
568 "unknown extensible field '{}' in sort (not declared in the registry)",
569 s.field
570 ))
571 })?;
572 if !def.sortable {
573 return Err(AppError::Validation(format!(
574 "extensible field '{}' is not sortable",
575 s.field
576 )));
577 }
578 let canonical = def.canonical();
579 let json_expr = dialect.json_extract_typed(&qualify(head), key, &canonical);
580 parts.push(format!("{} {}", json_expr, dir(s.desc)));
581 continue;
582 }
583 }
585 if col_names.contains(s.field.as_str()) {
586 parts.push(format!("{} {}", qualify(&s.field), dir(s.desc)));
587 }
588 }
589
590 if parts.is_empty() {
591 Ok(format!(" ORDER BY {}", qualify(pk)))
592 } else {
593 Ok(format!(" ORDER BY {}", parts.join(", ")))
594 }
595}
596
597pub fn select_by_id(
599 entity: &ResolvedEntity,
600 schema_override: Option<&str>,
601 dialect: &dyn Dialect,
602) -> QueryBuf {
603 let mut q = QueryBuf::new();
604 let schema = resolve_schema(entity, schema_override);
605 let table = qualified_table(schema, &entity.table_name);
606 let pk = &entity.pk_columns[0];
607 let cols = select_column_list(entity);
608 let ph = pk_placeholder(entity, 1, dialect);
609 q.sql = format!(
610 "SELECT {} FROM {} WHERE {} = {}",
611 cols,
612 table,
613 quoted(pk),
614 ph
615 );
616 q
617}
618
619#[allow(clippy::too_many_arguments)]
623pub fn select_list_with_includes(
624 entity: &ResolvedEntity,
625 filter: Option<&FilterNode>,
626 sort: &[SortSpec],
627 limit: Option<u32>,
628 offset: Option<u32>,
629 includes: &[IncludeSelect<'_>],
630 filter_includes: &[IncludeSelect<'_>],
631 schema_override: Option<&str>,
632 dialect: &dyn Dialect,
633 registry: Option<&ExtensibleRegistry>,
634) -> Result<QueryBuf, AppError> {
635 let mut q = QueryBuf::new();
636 let schema = resolve_schema(entity, schema_override);
637 let table = qualified_table(schema, &entity.table_name);
638 const MAIN_ALIAS: &str = "main";
639 let main_qualifier = format!("{}.", MAIN_ALIAS);
640
641 let main_cols: Vec<String> = entity
642 .columns
643 .iter()
644 .map(|c| {
645 let q = quoted(&c.name);
646 let pg_type = c.pg_type.as_deref().unwrap_or("");
647 let expr = if pg_type.contains('.')
648 || pg_type == "numeric"
649 || pg_type == "time"
650 || pg_type == "timetz"
651 {
652 format!("{}.{}::text", MAIN_ALIAS, q)
653 } else {
654 format!("{}.{}", MAIN_ALIAS, q)
655 };
656 format!("{} AS {}", expr, q)
657 })
658 .collect();
659
660 let mut select_parts = main_cols;
661 for inc in includes {
662 let rel_schema = resolve_schema(inc.related, schema_override);
663 let rel_table = qualified_table(rel_schema, &inc.related.table_name);
664 let sub_from = format!(
665 "{} WHERE {} = {}.{}",
666 rel_table,
667 quoted(inc.their_key),
668 MAIN_ALIAS,
669 quoted(inc.our_key)
670 );
671 let rel_col_exprs: Vec<String> = inc
672 .related
673 .columns
674 .iter()
675 .map(|c| dialect.quote_ident(&c.name))
676 .collect();
677 let subquery = match inc.direction {
678 IncludeDirection::ToOne => dialect.to_one_subquery(&rel_col_exprs, &sub_from),
679 IncludeDirection::ToMany => dialect.to_many_subquery(&rel_col_exprs, &sub_from),
680 };
681 select_parts.push(format!("{} AS {}", subquery, quoted(inc.name)));
682 }
683
684 let where_clause = match filter {
685 Some(node) => {
686 let frag = rsql_to_sql(
687 node,
688 entity,
689 &mut q,
690 Some(&main_qualifier),
691 filter_includes,
692 schema_override,
693 dialect,
694 registry,
695 )?;
696 format!(" WHERE {}", frag)
697 }
698 None => String::new(),
699 };
700 let order_clause = build_order_by(sort, entity, Some(&main_qualifier), dialect, registry)?;
701 let limit_clause = limit
702 .map(|n| format!(" LIMIT {}", n.min(1000)))
703 .unwrap_or_default();
704 let offset_clause = offset.map(|n| format!(" OFFSET {}", n)).unwrap_or_default();
705
706 q.sql = format!(
707 "SELECT {} FROM {} {}{}{}{}{}",
708 select_parts.join(", "),
709 table,
710 MAIN_ALIAS,
711 where_clause,
712 order_clause,
713 limit_clause,
714 offset_clause
715 );
716 Ok(q)
717}
718
719#[allow(clippy::too_many_arguments)]
724pub fn select_list(
725 entity: &ResolvedEntity,
726 filter: Option<&FilterNode>,
727 sort: &[SortSpec],
728 limit: Option<u32>,
729 offset: Option<u32>,
730 filter_includes: &[IncludeSelect<'_>],
731 schema_override: Option<&str>,
732 dialect: &dyn Dialect,
733 registry: Option<&ExtensibleRegistry>,
734) -> Result<QueryBuf, AppError> {
735 let mut q = QueryBuf::new();
736 let schema = resolve_schema(entity, schema_override);
737 let table = qualified_table(schema, &entity.table_name);
738
739 let where_clause = match filter {
740 Some(node) => {
741 let frag = rsql_to_sql(
742 node,
743 entity,
744 &mut q,
745 None,
746 filter_includes,
747 schema_override,
748 dialect,
749 registry,
750 )?;
751 format!(" WHERE {}", frag)
752 }
753 None => String::new(),
754 };
755 let order_clause = build_order_by(sort, entity, None, dialect, registry)?;
756 let limit_clause = limit
757 .map(|n| format!(" LIMIT {}", n.min(1000)))
758 .unwrap_or_default();
759 let offset_clause = offset.map(|n| format!(" OFFSET {}", n)).unwrap_or_default();
760 let cols = select_column_list(entity);
761 q.sql = format!(
762 "SELECT {} FROM {}{}{}{}{}",
763 cols, table, where_clause, order_clause, limit_clause, offset_clause
764 );
765 Ok(q)
766}
767
768pub fn select_by_column_in(
770 entity: &ResolvedEntity,
771 column_name: &str,
772 values: &[Value],
773 schema_override: Option<&str>,
774 dialect: &dyn Dialect,
775) -> QueryBuf {
776 let mut q = QueryBuf::new();
777 let schema = resolve_schema(entity, schema_override);
778 let table = qualified_table(schema, &entity.table_name);
779 let pk = &entity.pk_columns[0];
780 if values.is_empty() {
781 let cols = select_column_list(entity);
782 q.sql = format!("SELECT {} FROM {} WHERE 1 = 0", cols, table);
783 return q;
784 }
785 let placeholders: Vec<String> = values
786 .iter()
787 .map(|v| {
788 let n = q.push_param(v.clone());
789 entity
790 .columns
791 .iter()
792 .find(|c| c.name == column_name)
793 .and_then(|c| c.pg_type.as_deref())
794 .map(|t| dialect.cast_expr(&dialect.placeholder(n as usize), t))
795 .unwrap_or_else(|| dialect.placeholder(n as usize))
796 })
797 .collect();
798 let cols = select_column_list(entity);
799 q.sql = format!(
800 "SELECT {} FROM {} WHERE {} IN ({}) ORDER BY {}",
801 cols,
802 table,
803 quoted(column_name),
804 placeholders.join(", "),
805 quoted(pk)
806 );
807 q
808}
809
810pub fn insert(
815 entity: &ResolvedEntity,
816 body: &HashMap<String, Value>,
817 include_pk: bool,
818 schema_override: Option<&str>,
819 rls_tenant_id: Option<&str>,
820 caller_user_id: Option<&str>,
821 dialect: &dyn Dialect,
822) -> QueryBuf {
823 let mut q = QueryBuf::new();
824 let schema = resolve_schema(entity, schema_override);
825 let table = qualified_table(schema, &entity.table_name);
826 let mut cols = Vec::new();
827 let mut placeholders = Vec::new();
828 for c in &entity.columns {
829 let name = &c.name;
830 if c.pk_type.is_some() && !include_pk {
831 continue;
832 }
833 if entity.archive_field.as_deref().is_some_and(|af| name == af) {
835 continue;
836 }
837 if name == "updated_by" {
839 continue;
840 }
841 let val = if name == "created_by" {
842 caller_user_id
843 .map(|uid| Value::String(uid.to_string()))
844 .or_else(|| body.get(name).cloned())
845 } else {
846 body.get(name).cloned()
847 };
848 if val.is_none() && c.has_default {
849 continue;
850 }
851 let val = val.unwrap_or(Value::Null);
852 let val = coerce_json_value_for_pg_array(val, c.pg_type.as_deref());
853 let param_num = q.push_param(val);
854 let ph = c
855 .pg_type
856 .as_deref()
857 .map(|t| dialect.cast_expr(&dialect.placeholder(param_num as usize), t))
858 .unwrap_or_else(|| dialect.placeholder(param_num as usize));
859 cols.push(quoted(name));
860 placeholders.push(ph);
861 }
862 if let Some(tid) = rls_tenant_id {
863 let param_num = q.push_param(Value::String(tid.to_string()));
864 cols.push(quoted("tenant_id"));
865 placeholders.push(dialect.placeholder(param_num as usize));
866 }
867 let col_list = select_column_list(entity);
868 let ret = dialect.returning_clause(&col_list);
869 let suffix = if ret.is_empty() {
870 String::new()
871 } else {
872 format!(" {}", ret)
873 };
874 q.sql = format!(
875 "INSERT INTO {} ({}) VALUES ({}){}",
876 table,
877 cols.join(", "),
878 placeholders.join(", "),
879 suffix
880 );
881 q
882}
883
884pub fn update(
887 entity: &ResolvedEntity,
888 id: &Value,
889 body: &HashMap<String, Value>,
890 schema_override: Option<&str>,
891 caller_user_id: Option<&str>,
892 dialect: &dyn Dialect,
893) -> QueryBuf {
894 let mut q = QueryBuf::new();
895 let schema = resolve_schema(entity, schema_override);
896 let table = qualified_table(schema, &entity.table_name);
897 let pk = &entity.pk_columns[0];
898 let col_by_name: std::collections::HashMap<_, _> = entity
899 .columns
900 .iter()
901 .map(|c| (c.name.as_str(), c))
902 .collect();
903 let mut sets = Vec::new();
904 for (k, v) in body {
905 if *k == *pk {
906 continue;
907 }
908 if k == "tenant_id" {
909 continue;
910 }
911 if entity.archive_field.as_deref().is_some_and(|af| k == af) {
913 continue;
914 }
915 let Some(c) = col_by_name.get(k.as_str()) else {
916 continue;
917 };
918 let v = coerce_json_value_for_pg_array(v.clone(), c.pg_type.as_deref());
919 let param_num = q.push_param(v);
920 let rhs = c
921 .pg_type
922 .as_deref()
923 .map(|t| dialect.cast_expr(&dialect.placeholder(param_num as usize), t))
924 .unwrap_or_else(|| dialect.placeholder(param_num as usize));
925 sets.push(format!("{} = {}", quoted(k), rhs));
926 }
927 sets.push(format!("{} = {}", quoted("updated_at"), dialect.now_fn()));
928 if let Some(uid) = caller_user_id {
929 if entity.columns.iter().any(|c| c.name == "updated_by") {
930 let param_num = q.push_param(Value::String(uid.to_string()));
931 sets.push(format!(
932 "{} = {}",
933 quoted("updated_by"),
934 dialect.placeholder(param_num as usize)
935 ));
936 }
937 }
938 if sets.is_empty() {
939 let cols = select_column_list(entity);
940 let ph = pk_placeholder(entity, 1, dialect);
941 q.sql = format!(
942 "SELECT {} FROM {} WHERE {} = {}",
943 cols,
944 table,
945 quoted(pk),
946 ph
947 );
948 q.params.push(id.clone());
949 return q;
950 }
951 let set_clause = sets.join(", ");
952 let id_param = q.params.len() + 1;
953 q.params.push(id.clone());
954 let ph = pk_placeholder(entity, id_param, dialect);
955 let col_list = select_column_list(entity);
956 let ret = dialect.returning_clause(&col_list);
957 let suffix = if ret.is_empty() {
958 String::new()
959 } else {
960 format!(" {}", ret)
961 };
962 q.sql = format!(
963 "UPDATE {} SET {} WHERE {} = {}{}",
964 table,
965 set_clause,
966 quoted(pk),
967 ph,
968 suffix
969 );
970 q
971}
972
973pub fn delete(
975 entity: &ResolvedEntity,
976 schema_override: Option<&str>,
977 dialect: &dyn Dialect,
978) -> QueryBuf {
979 let mut q = QueryBuf::new();
980 let schema = resolve_schema(entity, schema_override);
981 let table = qualified_table(schema, &entity.table_name);
982 let pk = &entity.pk_columns[0];
983 let ph = pk_placeholder(entity, 1, dialect);
984 q.params.push(Value::Null);
985 let col_list = select_column_list(entity);
986 let ret = dialect.returning_clause(&col_list);
987 let suffix = if ret.is_empty() {
988 String::new()
989 } else {
990 format!(" {}", ret)
991 };
992 q.sql = format!(
993 "DELETE FROM {} WHERE {} = {}{}",
994 table,
995 quoted(pk),
996 ph,
997 suffix
998 );
999 q
1000}
1001
1002pub fn unarchive(
1005 entity: &ResolvedEntity,
1006 archive_field: &str,
1007 id: &Value,
1008 caller_user_id: Option<&str>,
1009 schema_override: Option<&str>,
1010 dialect: &dyn Dialect,
1011) -> QueryBuf {
1012 let mut q = QueryBuf::new();
1013 let schema = resolve_schema(entity, schema_override);
1014 let table = qualified_table(schema, &entity.table_name);
1015 let pk = &entity.pk_columns[0];
1016 let mut sets = vec![format!("{} = NULL", quoted(archive_field))];
1017 if entity.columns.iter().any(|c| c.name == "updated_at") {
1019 sets.push(format!("{} = {}", quoted("updated_at"), dialect.now_fn()));
1020 }
1021 if let Some(uid) = caller_user_id {
1022 if entity.columns.iter().any(|c| c.name == "updated_by") {
1023 let param_num = q.push_param(Value::String(uid.to_string()));
1024 sets.push(format!(
1025 "{} = {}",
1026 quoted("updated_by"),
1027 dialect.placeholder(param_num as usize)
1028 ));
1029 }
1030 }
1031 let id_param = q.params.len() + 1;
1033 q.params.push(id.clone());
1034 let ph = pk_placeholder(entity, id_param, dialect);
1035 let col_list = select_column_list(entity);
1036 let ret = dialect.returning_clause(&col_list);
1037 let suffix = if ret.is_empty() {
1038 String::new()
1039 } else {
1040 format!(" {}", ret)
1041 };
1042 q.sql = format!(
1043 "UPDATE {} SET {} WHERE {} = {} AND {} IS NOT NULL{}",
1044 table,
1045 sets.join(", "),
1046 quoted(pk),
1047 ph,
1048 quoted(archive_field),
1049 suffix
1050 );
1051 q
1052}
1053
1054pub fn insert_history_snapshot(
1060 entity: &ResolvedEntity,
1061 operation: &str,
1062 schema_override: Option<&str>,
1063 dialect: &dyn Dialect,
1064) -> QueryBuf {
1065 let mut q = QueryBuf::new();
1066 let schema = resolve_schema(entity, schema_override);
1067 let main_table = qualified_table(schema, &entity.table_name);
1068 let history_table = qualified_table(schema, &format!("{}_history", entity.table_name));
1069 let pk = &entity.pk_columns[0];
1070
1071 let op_ph = dialect.placeholder(1);
1073 let pk_ph = pk_placeholder(entity, 2, dialect);
1074
1075 let col_names: Vec<String> = entity.columns.iter().map(|c| quoted(&c.name)).collect();
1076 let col_list = col_names.join(", ");
1077
1078 q.sql = format!(
1079 "INSERT INTO {history} (\
1080 \"_version\", \"_operation\", \"_recorded_at\", \"_valid_from\", \"_valid_to\", {cols}\
1081 ) \
1082 SELECT \
1083 COALESCE(\"_version\", 1), {op_ph}, {now}, \"updated_at\", {now}, {cols} \
1084 FROM {main} \
1085 WHERE {pk_q} = {pk_ph}",
1086 history = history_table,
1087 cols = col_list,
1088 op_ph = op_ph,
1089 now = dialect.now_fn(),
1090 main = main_table,
1091 pk_q = quoted(pk),
1092 pk_ph = pk_ph,
1093 );
1094 q.params.push(Value::String(operation.to_string()));
1095 q.params.push(Value::Null); q
1097}
1098
1099pub fn select_history_list(
1102 entity: &ResolvedEntity,
1103 schema_override: Option<&str>,
1104 dialect: &dyn Dialect,
1105) -> QueryBuf {
1106 let mut q = QueryBuf::new();
1107 let schema = resolve_schema(entity, schema_override);
1108 let history_table = qualified_table(schema, &format!("{}_history", entity.table_name));
1109 let pk = &entity.pk_columns[0];
1110 let pk_ph = pk_placeholder(entity, 1, dialect);
1111 q.sql = format!(
1112 "SELECT * FROM {} WHERE {} = {} ORDER BY {} DESC",
1113 history_table,
1114 quoted(pk),
1115 pk_ph,
1116 quoted("_version")
1117 );
1118 q.params.push(Value::Null); q
1120}
1121
1122pub fn select_history_by_version(
1125 entity: &ResolvedEntity,
1126 schema_override: Option<&str>,
1127 dialect: &dyn Dialect,
1128) -> QueryBuf {
1129 let mut q = QueryBuf::new();
1130 let schema = resolve_schema(entity, schema_override);
1131 let history_table = qualified_table(schema, &format!("{}_history", entity.table_name));
1132 let pk = &entity.pk_columns[0];
1133 let pk_ph = pk_placeholder(entity, 1, dialect);
1134 let v_ph = dialect.placeholder(2);
1135 q.sql = format!(
1136 "SELECT * FROM {} WHERE {} = {} AND {} = {}",
1137 history_table,
1138 quoted(pk),
1139 pk_ph,
1140 quoted("_version"),
1141 v_ph
1142 );
1143 q.params.push(Value::Null); q.params.push(Value::Null); q
1146}
1147
1148pub fn prune_history(
1151 entity: &ResolvedEntity,
1152 schema_override: Option<&str>,
1153 dialect: &dyn Dialect,
1154) -> QueryBuf {
1155 let mut q = QueryBuf::new();
1156 let schema = resolve_schema(entity, schema_override);
1157 let history_table = qualified_table(schema, &format!("{}_history", entity.table_name));
1158 let pk = &entity.pk_columns[0];
1159 let pk_ph = pk_placeholder(entity, 1, dialect);
1160 let keep_ph = dialect.placeholder(2);
1161 q.sql = format!(
1162 "DELETE FROM {tbl} WHERE {pk_q} = {pk_ph} \
1163 AND \"_history_id\" NOT IN (\
1164 SELECT \"_history_id\" FROM {tbl} WHERE {pk_q} = {pk_ph} \
1165 ORDER BY \"_version\" DESC LIMIT {keep_ph}\
1166 )",
1167 tbl = history_table,
1168 pk_q = quoted(pk),
1169 pk_ph = pk_ph,
1170 keep_ph = keep_ph,
1171 );
1172 q.params.push(Value::Null); q.params.push(Value::Null); q
1175}
1176
1177#[cfg(test)]
1180mod versioning_tests {
1181 use super::*;
1182 use crate::config::resolved::{ColumnInfo, PkType, ResolvedEntity};
1183 use std::collections::{HashMap, HashSet};
1184
1185 struct PgDialect;
1186 impl crate::db::Dialect for PgDialect {
1187 fn name(&self) -> &'static str {
1188 "postgres"
1189 }
1190 fn placeholder(&self, n: usize) -> String {
1191 format!("${}", n)
1192 }
1193 fn quote_ident(&self, s: &str) -> String {
1194 format!("\"{}\"", s)
1195 }
1196 fn ddl_type(&self, _: &crate::db::CanonicalType) -> String {
1197 "TEXT".into()
1198 }
1199 fn cast_name(&self, _: &crate::db::CanonicalType) -> Option<String> {
1200 None
1201 }
1202 fn type_category(&self, _: &crate::db::CanonicalType) -> crate::db::TypeCategory {
1203 crate::db::TypeCategory::Text
1204 }
1205 fn type_support(&self, _: &crate::db::CanonicalType) -> crate::db::TypeSupport {
1206 crate::db::TypeSupport::Native("text")
1207 }
1208 fn cast_expr(&self, expr: &str, _: &str) -> String {
1209 expr.to_string()
1210 }
1211 fn now_fn(&self) -> &'static str {
1212 "NOW()"
1213 }
1214 fn sys_timestamp_type(&self) -> &'static str {
1215 "TIMESTAMPTZ"
1216 }
1217 fn audit_timestamp_type(&self) -> &'static str {
1218 "TIMESTAMPTZ"
1219 }
1220 fn sys_bigserial_type(&self) -> &'static str {
1221 "BIGSERIAL"
1222 }
1223 fn sys_bytes_type(&self) -> &'static str {
1224 "BYTEA"
1225 }
1226 fn sys_json_type(&self) -> &'static str {
1227 "JSONB"
1228 }
1229 fn uuid_default_expr(&self) -> &'static str {
1230 "gen_random_uuid()"
1231 }
1232 fn returning_clause(&self, cols: &str) -> String {
1233 format!("RETURNING {}", cols)
1234 }
1235 fn upsert_conflict(&self, _: &[&str], _: &str) -> String {
1236 String::new()
1237 }
1238 fn to_one_subquery(&self, _col_exprs: &[String], from_clause: &str) -> String {
1239 format!("(SELECT row_to_json(t) FROM ({}) t)", from_clause)
1240 }
1241 fn to_many_subquery(&self, _col_exprs: &[String], from_clause: &str) -> String {
1242 format!("(SELECT json_agg(t) FROM ({}) t)", from_clause)
1243 }
1244 fn supports_schemas(&self) -> bool {
1245 true
1246 }
1247 fn supports_rls(&self) -> bool {
1248 true
1249 }
1250 fn supports_named_enum_types(&self) -> bool {
1251 true
1252 }
1253 fn supports_index_include(&self) -> bool {
1254 true
1255 }
1256 fn set_tenant_session_sql(&self, _: &str) -> Option<String> {
1257 None
1258 }
1259 fn json_extract_text(&self, col: &str, key: &str) -> String {
1260 format!("({} ->> '{}')", col, key.replace('\'', "''"))
1261 }
1262 fn json_extract_typed(
1263 &self,
1264 col: &str,
1265 key: &str,
1266 _t: &crate::db::CanonicalType,
1267 ) -> String {
1268 self.json_extract_text(col, key)
1269 }
1270 fn case_insensitive_like(&self, col: &str, placeholder: &str) -> String {
1271 format!("{} ILIKE {}", col, placeholder)
1272 }
1273 }
1274
1275 fn make_entity() -> ResolvedEntity {
1276 ResolvedEntity {
1277 table_id: "t1".into(),
1278 schema_name: "myschema".into(),
1279 table_name: "users".into(),
1280 path_segment: "users".into(),
1281 pk_columns: vec!["id".into()],
1282 pk_type: PkType::Uuid,
1283 columns: vec![
1284 ColumnInfo {
1285 name: "id".into(),
1286 pk_type: Some(PkType::Uuid),
1287 nullable: false,
1288 has_default: true,
1289 pg_type: Some("uuid".into()),
1290 is_asset: false,
1291 asset_is_array: false,
1292 asset_config: None,
1293 },
1294 ColumnInfo {
1295 name: "name".into(),
1296 pk_type: None,
1297 nullable: true,
1298 has_default: false,
1299 pg_type: None,
1300 is_asset: false,
1301 asset_is_array: false,
1302 asset_config: None,
1303 },
1304 ColumnInfo {
1305 name: "updated_at".into(),
1306 pk_type: None,
1307 nullable: false,
1308 has_default: true,
1309 pg_type: Some("timestamptz".into()),
1310 is_asset: false,
1311 asset_is_array: false,
1312 asset_config: None,
1313 },
1314 ],
1315 operations: vec![],
1316 sensitive_columns: HashSet::new(),
1317 includes: vec![],
1318 validation: HashMap::new(),
1319 events: vec![],
1320 archive_field: None,
1321 package_id: String::new(),
1322 audit_log: false,
1323 global: false,
1324 parent_ref_column: None,
1325 versioning: None,
1326 mcp: None,
1327 extensible_columns: vec![],
1328 }
1329 }
1330
1331 #[test]
1332 fn insert_history_snapshot_inserts_into_history_table() {
1333 let entity = make_entity();
1334 let d = PgDialect;
1335 let q = insert_history_snapshot(&entity, "update", None, &d);
1336 assert!(q.sql.contains("INSERT INTO"));
1337 assert!(q.sql.contains("_history"));
1338 assert!(q.sql.contains("_version"));
1339 assert!(q.sql.contains("_operation"));
1340 assert!(q.sql.contains("\"name\""));
1341 assert_eq!(q.params[0], Value::String("update".into()));
1342 }
1343
1344 #[test]
1345 fn insert_history_snapshot_uses_select_not_application_values() {
1346 let entity = make_entity();
1347 let d = PgDialect;
1348 let q = insert_history_snapshot(&entity, "delete", None, &d);
1349 assert!(q.sql.contains("SELECT"));
1350 assert!(q.sql.contains("FROM"));
1351 }
1352
1353 #[test]
1354 fn select_history_list_orders_by_version_desc() {
1355 let entity = make_entity();
1356 let d = PgDialect;
1357 let q = select_history_list(&entity, None, &d);
1358 assert!(q.sql.contains("ORDER BY"));
1359 assert!(q.sql.contains("_version"));
1360 assert!(q.sql.contains("DESC"));
1361 assert_eq!(q.params.len(), 1);
1362 }
1363
1364 #[test]
1365 fn select_history_by_version_has_two_params() {
1366 let entity = make_entity();
1367 let d = PgDialect;
1368 let q = select_history_by_version(&entity, None, &d);
1369 assert!(q.sql.contains("$1"));
1370 assert!(q.sql.contains("$2"));
1371 assert_eq!(q.params.len(), 2);
1372 }
1373
1374 #[test]
1375 fn prune_history_contains_limit() {
1376 let entity = make_entity();
1377 let d = PgDialect;
1378 let q = prune_history(&entity, None, &d);
1379 assert!(q.sql.to_uppercase().contains("LIMIT"));
1380 assert!(q.sql.contains("$2"));
1381 }
1382
1383 #[test]
1384 fn history_table_uses_entity_schema() {
1385 let entity = make_entity();
1386 let d = PgDialect;
1387 let q = select_history_list(&entity, None, &d);
1388 assert!(q.sql.contains("\"myschema\""));
1389 assert!(q.sql.contains("\"users_history\""));
1390 }
1391
1392 #[test]
1393 fn schema_override_is_respected() {
1394 let entity = make_entity();
1395 let d = PgDialect;
1396 let q = select_history_list(&entity, Some("tenant1"), &d);
1397 assert!(q.sql.contains("\"tenant1\""));
1398 assert!(!q.sql.contains("\"myschema\""));
1399 }
1400
1401 #[test]
1402 fn coerce_array_splits_comma_separated_string() {
1403 let v =
1405 coerce_json_value_for_pg_array(Value::String("id1, id2".to_string()), Some("uuid[]"));
1406 assert_eq!(v, Value::String("{\"id1\",\"id2\"}".to_string()));
1407 }
1408
1409 #[test]
1410 fn coerce_array_single_string_is_one_element() {
1411 let v = coerce_json_value_for_pg_array(Value::String("id1".to_string()), Some("uuid[]"));
1412 assert_eq!(v, Value::String("{\"id1\"}".to_string()));
1413 }
1414
1415 #[test]
1416 fn coerce_array_drops_empty_segments() {
1417 let v = coerce_json_value_for_pg_array(
1418 Value::String("id1, , id2,".to_string()),
1419 Some("text[]"),
1420 );
1421 assert_eq!(v, Value::String("{\"id1\",\"id2\"}".to_string()));
1422 }
1423
1424 #[test]
1425 fn coerce_array_json_array_is_not_split() {
1426 let v = coerce_json_value_for_pg_array(
1428 Value::Array(vec![Value::String("a,b".to_string())]),
1429 Some("text[]"),
1430 );
1431 assert_eq!(v, Value::String("{\"a,b\"}".to_string()));
1432 }
1433
1434 #[test]
1435 fn coerce_array_noop_for_non_array_column() {
1436 let v = coerce_json_value_for_pg_array(Value::String("id1, id2".to_string()), Some("uuid"));
1437 assert_eq!(v, Value::String("id1, id2".to_string()));
1438 }
1439
1440 #[cfg(feature = "postgres")]
1441 fn entity_with_pk(pk_type: PkType) -> ResolvedEntity {
1442 let mut e = make_entity();
1443 e.pk_type = pk_type;
1444 e
1445 }
1446
1447 #[cfg(feature = "postgres")]
1448 #[test]
1449 fn select_by_id_casts_uuid_pk() {
1450 let d = crate::db::PostgresDialect;
1451 let q = select_by_id(&entity_with_pk(PkType::Uuid), None, &d);
1452 assert!(q.sql.contains("\"id\" = $1::uuid"), "got: {}", q.sql);
1453 }
1454
1455 #[cfg(feature = "postgres")]
1456 #[test]
1457 fn select_by_id_casts_bigint_pk() {
1458 let d = crate::db::PostgresDialect;
1461 let q = select_by_id(&entity_with_pk(PkType::BigInt), None, &d);
1462 assert!(q.sql.contains("\"id\" = $1::bigint"), "got: {}", q.sql);
1463 }
1464
1465 #[cfg(feature = "postgres")]
1466 #[test]
1467 fn select_by_id_casts_int_pk() {
1468 let d = crate::db::PostgresDialect;
1469 let q = select_by_id(&entity_with_pk(PkType::Int), None, &d);
1470 assert!(q.sql.contains("\"id\" = $1::integer"), "got: {}", q.sql);
1471 }
1472
1473 #[cfg(feature = "postgres")]
1474 #[test]
1475 fn select_by_column_in_casts_bigserial_join_column() {
1476 use crate::db::{active_cast_name, CanonicalType};
1481 let d = crate::db::PostgresDialect;
1482 let mut e = make_entity();
1483 e.columns[0].pg_type = active_cast_name(&CanonicalType::BigSerial);
1485 let q = select_by_column_in(&e, "id", &[Value::String("5".into())], None, &d);
1486 assert!(
1487 q.sql.contains("\"id\" IN ($1::bigint)"),
1488 "bigserial join column must be cast: {}",
1489 q.sql
1490 );
1491 }
1492
1493 #[cfg(feature = "postgres")]
1494 #[test]
1495 fn select_by_id_leaves_text_pk_uncast() {
1496 let d = crate::db::PostgresDialect;
1497 let q = select_by_id(&entity_with_pk(PkType::Text), None, &d);
1498 assert!(q.sql.contains("\"id\" = $1"), "got: {}", q.sql);
1499 assert!(
1500 !q.sql.contains("$1::"),
1501 "text PK should not be cast: {}",
1502 q.sql
1503 );
1504 }
1505
1506 fn entity_with_bag() -> ResolvedEntity {
1507 let mut e = make_entity();
1508 e.extensible_columns = vec!["attributes".into()];
1509 e
1510 }
1511
1512 fn ext_registry() -> ExtensibleRegistry {
1513 ExtensibleRegistry::from_value(serde_json::json!({
1514 "attributes": [
1515 {"key": "warrantyMonths", "type": "int"},
1516 {"key": "energyRating", "type": "text"},
1517 {"key": "notes", "type": "text", "filterable": false, "sortable": false}
1518 ]
1519 }))
1520 .unwrap()
1521 }
1522
1523 #[cfg(feature = "postgres")]
1524 #[test]
1525 fn rsql_filters_and_sorts_on_extensible_field() {
1526 use crate::sql::rsql::{parse_rsql, parse_sort};
1527 let d = crate::db::PostgresDialect;
1528 let e = entity_with_bag();
1529 let reg = ext_registry();
1530 let filter = parse_rsql("attributes.warrantyMonths=ge=12").unwrap();
1531 let sort = parse_sort("-attributes.warrantyMonths");
1532 let q = select_list(
1533 &e,
1534 Some(&filter),
1535 &sort,
1536 Some(10),
1537 Some(0),
1538 &[],
1539 None,
1540 &d,
1541 Some(®),
1542 )
1543 .unwrap();
1544 assert!(
1545 q.sql
1546 .contains("(\"attributes\" ->> 'warrantyMonths')::integer >= $1::integer"),
1547 "got: {}",
1548 q.sql
1549 );
1550 assert!(
1551 q.sql
1552 .contains("ORDER BY (\"attributes\" ->> 'warrantyMonths')::integer DESC"),
1553 "got: {}",
1554 q.sql
1555 );
1556 assert_eq!(q.params.len(), 1);
1557 }
1558
1559 #[test]
1560 fn rsql_text_extensible_field_uses_case_insensitive_like() {
1561 let d = PgDialect;
1562 let e = entity_with_bag();
1563 let reg = ext_registry();
1564 let filter = crate::sql::rsql::parse_rsql("attributes.energyRating=contains=plus").unwrap();
1565 let q = select_list(
1566 &e,
1567 Some(&filter),
1568 &[],
1569 None,
1570 None,
1571 &[],
1572 None,
1573 &d,
1574 Some(®),
1575 )
1576 .unwrap();
1577 assert!(
1578 q.sql
1579 .contains("(\"attributes\" ->> 'energyRating') ILIKE $1"),
1580 "got: {}",
1581 q.sql
1582 );
1583 }
1584
1585 #[test]
1586 fn rsql_unknown_extensible_field_is_rejected() {
1587 let d = PgDialect;
1588 let e = entity_with_bag();
1589 let reg = ext_registry();
1590 let filter = crate::sql::rsql::parse_rsql("attributes.bogus==1").unwrap();
1591 let r = select_list(
1592 &e,
1593 Some(&filter),
1594 &[],
1595 None,
1596 None,
1597 &[],
1598 None,
1599 &d,
1600 Some(®),
1601 );
1602 assert!(r.is_err());
1603 }
1604
1605 #[test]
1606 fn rsql_non_filterable_extensible_field_is_rejected() {
1607 let d = PgDialect;
1608 let e = entity_with_bag();
1609 let reg = ext_registry();
1610 let filter = crate::sql::rsql::parse_rsql("attributes.notes==hi").unwrap();
1611 let r = select_list(
1612 &e,
1613 Some(&filter),
1614 &[],
1615 None,
1616 None,
1617 &[],
1618 None,
1619 &d,
1620 Some(®),
1621 );
1622 assert!(r.is_err());
1623 }
1624
1625 #[test]
1626 fn sort_on_non_sortable_extensible_field_is_rejected() {
1627 let d = PgDialect;
1628 let e = entity_with_bag();
1629 let reg = ext_registry();
1630 let sort = crate::sql::rsql::parse_sort("attributes.notes");
1631 let r = select_list(&e, None, &sort, None, None, &[], None, &d, Some(®));
1632 assert!(r.is_err());
1633 }
1634}
1635
1636pub fn archive(
1639 entity: &ResolvedEntity,
1640 archive_field: &str,
1641 id: &Value,
1642 caller_user_id: Option<&str>,
1643 schema_override: Option<&str>,
1644 dialect: &dyn Dialect,
1645) -> QueryBuf {
1646 let mut q = QueryBuf::new();
1647 let schema = resolve_schema(entity, schema_override);
1648 let table = qualified_table(schema, &entity.table_name);
1649 let pk = &entity.pk_columns[0];
1650 let mut sets = vec![format!("{} = {}", quoted(archive_field), dialect.now_fn())];
1651 if entity.columns.iter().any(|c| c.name == "updated_at") {
1653 sets.push(format!("{} = {}", quoted("updated_at"), dialect.now_fn()));
1654 }
1655 if let Some(uid) = caller_user_id {
1656 if entity.columns.iter().any(|c| c.name == "updated_by") {
1657 let param_num = q.push_param(Value::String(uid.to_string()));
1658 sets.push(format!(
1659 "{} = {}",
1660 quoted("updated_by"),
1661 dialect.placeholder(param_num as usize)
1662 ));
1663 }
1664 }
1665 let id_param = q.params.len() + 1;
1667 q.params.push(id.clone());
1668 let ph = pk_placeholder(entity, id_param, dialect);
1669 let col_list = select_column_list(entity);
1670 let ret = dialect.returning_clause(&col_list);
1671 let suffix = if ret.is_empty() {
1672 String::new()
1673 } else {
1674 format!(" {}", ret)
1675 };
1676 q.sql = format!(
1677 "UPDATE {} SET {} WHERE {} = {} AND {} IS NULL{}",
1678 table,
1679 sets.join(", "),
1680 quoted(pk),
1681 ph,
1682 quoted(archive_field),
1683 suffix
1684 );
1685 q
1686}