Skip to main content

reinhardt_admin/core/
database.rs

1//! Database integration for admin operations
2//!
3//! This module provides database access layer for admin CRUD operations,
4//! integrating with reinhardt-orm's QuerySet API.
5
6use crate::types::{AdminError, AdminResult};
7use async_trait::async_trait;
8use reinhardt_core::macros::injectable;
9use reinhardt_db::migrations::FieldType as DbFieldType;
10use reinhardt_db::orm::execution::convert_values;
11use reinhardt_db::orm::{
12	DatabaseConnection, Filter, FilterCondition, FilterOperator, FilterValue, Model,
13};
14use reinhardt_di::{DiResult, FactoryOutput, Injectable, InjectionContext};
15use reinhardt_query::prelude::{
16	Alias, BinOper, CaseStatement, ColumnRef, Condition, Expr, ExprTrait, IntoValue, Order,
17	PostgresQueryBuilder, Query, QueryStatementBuilder, SimpleExpr, Value,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21
22const ADMIN_LIST_TOTAL_COUNT_ALIAS: &str = "__reinhardt_total_count";
23const SENSITIVE_FIELDS: &[&str] = &["password_hash", "password_salt"];
24
25/// Converts a `serde_json::Value` into a reinhardt-query `Value`.
26///
27/// String values are inspected for ISO 8601 date/time patterns and converted
28/// to the appropriate chrono type so that PostgreSQL accepts them for
29/// `timestamptz`, `date`, and `time` columns without an explicit cast.
30fn json_to_sea_value(value: serde_json::Value) -> Value {
31	match value {
32		serde_json::Value::String(s) => {
33			// ISO 8601 datetime with timezone offset (e.g. "2026-04-02T16:45:50Z")
34			if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&s) {
35				Value::ChronoDateTimeUtc(Some(Box::new(dt.with_timezone(&chrono::Utc))))
36			// ISO 8601 datetime with fractional seconds and Z suffix
37			} else if let Ok(dt) =
38				chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.fZ")
39			{
40				Value::ChronoDateTimeUtc(Some(Box::new(dt.and_utc())))
41			// Date only
42			} else if s.len() == 10 {
43				if let Ok(d) = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") {
44					return Value::ChronoDate(Some(Box::new(d)));
45				}
46				Value::String(Some(Box::new(s)))
47			// Time only
48			} else if s.len() == 8 && s.chars().filter(|c| *c == ':').count() == 2 {
49				if let Ok(t) = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S") {
50					return Value::ChronoTime(Some(Box::new(t)));
51				}
52				Value::String(Some(Box::new(s)))
53			// UUID (8-4-4-4-12 hex pattern)
54			} else if s.len() == 36
55				&& s.chars().enumerate().all(|(i, c)| {
56					matches!(i, 8 | 13 | 18 | 23) && c == '-' || c.is_ascii_hexdigit()
57				}) {
58				if let Ok(uuid) = uuid::Uuid::parse_str(&s) {
59					return Value::Uuid(Some(Box::new(uuid)));
60				}
61				Value::String(Some(Box::new(s)))
62			} else {
63				Value::String(Some(Box::new(s)))
64			}
65		}
66		serde_json::Value::Number(n) => {
67			if let Some(i) = n.as_i64() {
68				Value::BigInt(Some(i))
69			} else if let Some(f) = n.as_f64() {
70				Value::Double(Some(f))
71			} else {
72				Value::String(Some(Box::new(n.to_string())))
73			}
74		}
75		serde_json::Value::Bool(b) => Value::Bool(Some(b)),
76		serde_json::Value::Null => Value::Int(None),
77		_ => Value::String(Some(Box::new(value.to_string()))),
78	}
79}
80use std::sync::Arc;
81
82/// Dummy record type for admin panel CRUD operations
83///
84/// This type exists solely to satisfy the `<M: Model>` generic constraint
85/// in `AdminDatabase` methods. The admin panel operates on dynamic data
86/// (serde_json::Value), not statically-typed models.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct AdminRecord {
89	/// The primary key identifier for the admin record.
90	pub id: Option<i64>,
91}
92
93/// Field accessors for `AdminRecord` used in typed query construction.
94#[derive(Debug, Clone)]
95pub struct AdminRecordFields {
96	/// Typed field accessor for the `id` column.
97	pub id: reinhardt_db::orm::query_fields::Field<AdminRecord, Option<i64>>,
98}
99
100impl Default for AdminRecordFields {
101	fn default() -> Self {
102		Self::new()
103	}
104}
105
106impl AdminRecordFields {
107	/// Creates a new set of field accessors with default column names.
108	pub fn new() -> Self {
109		Self {
110			id: reinhardt_db::orm::query_fields::Field::new(vec!["id".to_string()]),
111		}
112	}
113}
114
115impl reinhardt_db::orm::FieldSelector for AdminRecordFields {
116	fn with_alias(mut self, alias: &str) -> Self {
117		self.id = self.id.with_alias(alias);
118		self
119	}
120}
121
122impl Model for AdminRecord {
123	type PrimaryKey = i64;
124	type Fields = AdminRecordFields;
125	type Objects = reinhardt_db::orm::Manager<Self>;
126
127	fn table_name() -> &'static str {
128		"admin_records"
129	}
130
131	fn new_fields() -> Self::Fields {
132		AdminRecordFields::new()
133	}
134
135	fn primary_key(&self) -> Option<Self::PrimaryKey> {
136		self.id
137	}
138
139	fn set_primary_key(&mut self, pk: Self::PrimaryKey) {
140		self.id = Some(pk);
141	}
142}
143
144/// Converts a string primary key value to the appropriate SeaQuery `Value`
145/// based on the field's registered database type.
146///
147/// Looks up the field type from the migration registry. When the registry has
148/// metadata for the given table/field, the conversion is type-aware (e.g.
149/// UUID strings become `Value::Uuid`). Falls back to the i64-then-String
150/// heuristic when metadata is unavailable.
151fn parse_pk_value(table_name: &str, pk_field: &str, id: &str) -> Value {
152	if let Some(field_meta) =
153		crate::server::type_inference::get_field_metadata(table_name, pk_field)
154	{
155		match field_meta.field_type {
156			DbFieldType::Uuid => {
157				if let Ok(uuid) = uuid::Uuid::parse_str(id) {
158					return Value::Uuid(Some(Box::new(uuid)));
159				}
160			}
161			DbFieldType::BigInteger => {
162				if let Ok(num) = id.parse::<i64>() {
163					return Value::BigInt(Some(num));
164				}
165			}
166			DbFieldType::Integer
167			| DbFieldType::SmallInteger
168			| DbFieldType::TinyInt
169			| DbFieldType::MediumInt => {
170				if let Ok(num) = id.parse::<i32>() {
171					return Value::Int(Some(num));
172				}
173			}
174			_ => {}
175		}
176	}
177
178	// Fallback: existing heuristic for backward compatibility
179	if let Ok(num_id) = id.parse::<i64>() {
180		Value::BigInt(Some(num_id))
181	} else {
182		Value::String(Some(Box::new(id.to_string())))
183	}
184}
185
186/// Batch version of `parse_pk_value` for bulk operations.
187fn parse_pk_values(table_name: &str, pk_field: &str, ids: &[String]) -> Vec<Value> {
188	ids.iter()
189		.map(|id| parse_pk_value(table_name, pk_field, id))
190		.collect()
191}
192
193/// Convert `FilterValue` to `Value` while preserving typed scalar bindings.
194#[doc(hidden)]
195pub fn filter_value_to_sea_value(v: &FilterValue) -> Value {
196	match v {
197		FilterValue::String(s) => s.clone().into(),
198		FilterValue::Timestamp(value) => (*value).into(),
199		FilterValue::Date(value) => (*value).into(),
200		FilterValue::Time(value) => (*value).into(),
201		FilterValue::NaiveDateTime(value) => (*value).into(),
202		FilterValue::Decimal(value) => (*value).into(),
203		FilterValue::Uuid(value) => (*value).into(),
204		FilterValue::Integer(i) | FilterValue::Int(i) => (*i).into(),
205		FilterValue::Float(f) => (*f).into(),
206		FilterValue::Boolean(b) | FilterValue::Bool(b) => (*b).into(),
207		FilterValue::Null => Value::Int(None),
208		// Array values are not scalar; they are handled by In/NotIn arms
209		// in build_single_filter_expr(). Return None-string as fallback
210		// for unexpected scalar contexts.
211		FilterValue::Array(_) => Value::String(None),
212		FilterValue::List(_) | FilterValue::Range(_, _) => Value::String(None),
213		FilterValue::FieldRef(f) => {
214			// FieldRef generates column reference, not scalar value.
215			// For Value context, return field name as string.
216			// Proper handling is in build_single_filter_expr().
217			Value::String(Some(Box::new(f.field.clone())))
218		}
219		FilterValue::Expression(expr) => {
220			// Expression generates SQL expression, not scalar value.
221			// For Value context, return SQL string representation.
222			// Proper handling is in build_single_filter_expr().
223			Value::String(Some(Box::new(expr.to_sql())))
224		}
225		FilterValue::OuterRef(outer) => {
226			// OuterRef generates outer query reference, not scalar value.
227			// For Value context, return field name as string.
228			// Proper handling is in build_single_filter_expr().
229			Value::String(Some(Box::new(outer.field.clone())))
230		}
231	}
232}
233
234/// Convert an annotation `AnnotationValue` to a safe SeaQuery `SimpleExpr`.
235///
236/// Uses type-safe SeaQuery API for field references and literal values
237/// instead of raw SQL string interpolation, preventing SQL injection.
238fn annotation_value_to_safe_expr(
239	val: &reinhardt_db::orm::annotation::AnnotationValue,
240) -> SimpleExpr {
241	use reinhardt_db::orm::annotation::AnnotationValue;
242
243	match val {
244		AnnotationValue::Value(v) => {
245			use reinhardt_db::orm::annotation::Value as AnnotValue;
246			match v {
247				AnnotValue::String(s) => Expr::val(s.as_str()).into(),
248				AnnotValue::Int(i) => Expr::val(*i).into(),
249				AnnotValue::Float(f) => Expr::val(*f).into(),
250				AnnotValue::Bool(b) => Expr::val(*b).into(),
251				AnnotValue::Null => Expr::val(Option::<String>::None).into(),
252			}
253		}
254		AnnotationValue::Field(f) => Expr::col(Alias::new(&f.field)).into(),
255		AnnotationValue::Expression(e) => annotation_expr_to_safe_expr(e),
256		AnnotationValue::Aggregate(a) => aggregate_to_safe_expr(a),
257		// Subquery and PostgreSQL-specific aggregation types produce SQL
258		// from internally constructed ORM queries, not from user HTTP input.
259		// Their SQL output is safe because it's built through type-safe ORM APIs.
260		AnnotationValue::Subquery(_)
261		| AnnotationValue::ArrayAgg(_)
262		| AnnotationValue::StringAgg(_)
263		| AnnotationValue::JsonbAgg(_)
264		| AnnotationValue::JsonbBuildObject(_)
265		| AnnotationValue::TsRank(_) => Expr::cust(val.to_sql()).into(),
266	}
267}
268
269/// Convert an `Aggregate` to a safe SeaQuery `SimpleExpr`.
270///
271/// Uses parameterized function templates with quoted column identifiers
272/// instead of raw SQL string interpolation, preventing SQL injection
273/// through field name manipulation.
274fn aggregate_to_safe_expr(agg: &reinhardt_db::orm::aggregation::Aggregate) -> SimpleExpr {
275	use reinhardt_db::orm::aggregation::AggregateFunc;
276
277	let func_name = match agg.func {
278		AggregateFunc::Count | AggregateFunc::CountDistinct => "COUNT",
279		AggregateFunc::Sum => "SUM",
280		AggregateFunc::Avg => "AVG",
281		AggregateFunc::Max => "MAX",
282		AggregateFunc::Min => "MIN",
283	};
284
285	if let Some(field) = &agg.field {
286		let col_expr: SimpleExpr = Expr::col(Alias::new(field)).into();
287		let is_distinct = agg.distinct || matches!(agg.func, AggregateFunc::CountDistinct);
288		if is_distinct {
289			Expr::cust_with_values(format!("{func_name}(DISTINCT ?)"), [col_expr]).into()
290		} else {
291			Expr::cust_with_values(format!("{func_name}(?)"), [col_expr]).into()
292		}
293	} else {
294		// COUNT(*) case - static SQL template, no user input
295		Expr::cust(format!("{func_name}(*)")).into()
296	}
297}
298
299/// Convert an annotation `Expression` to a safe SeaQuery `SimpleExpr`.
300///
301/// Recursively converts all expression types using type-safe SeaQuery API
302/// for field references and values, preventing SQL injection through
303/// value manipulation in expression trees.
304fn annotation_expr_to_safe_expr(expr: &reinhardt_db::orm::annotation::Expression) -> SimpleExpr {
305	use reinhardt_db::orm::annotation::Expression as AnnotExpr;
306
307	match expr {
308		AnnotExpr::Add(left, right) => {
309			let left_expr = annotation_value_to_safe_expr(left);
310			let right_expr = annotation_value_to_safe_expr(right);
311			Expr::cust_with_values("(? + ?)", [left_expr, right_expr]).into()
312		}
313		AnnotExpr::Subtract(left, right) => {
314			let left_expr = annotation_value_to_safe_expr(left);
315			let right_expr = annotation_value_to_safe_expr(right);
316			Expr::cust_with_values("(? - ?)", [left_expr, right_expr]).into()
317		}
318		AnnotExpr::Multiply(left, right) => {
319			let left_expr = annotation_value_to_safe_expr(left);
320			let right_expr = annotation_value_to_safe_expr(right);
321			Expr::cust_with_values("(? * ?)", [left_expr, right_expr]).into()
322		}
323		AnnotExpr::Divide(left, right) => {
324			let left_expr = annotation_value_to_safe_expr(left);
325			let right_expr = annotation_value_to_safe_expr(right);
326			Expr::cust_with_values("(? / ?)", [left_expr, right_expr]).into()
327		}
328		AnnotExpr::Case { whens, default } => {
329			let mut case = CaseStatement::new();
330			for when in whens {
331				// Q conditions are constructed internally by the ORM's query builder,
332				// not from user HTTP input. The THEN values are safely converted
333				// through annotation_value_to_safe_expr.
334				let cond_expr: SimpleExpr = Expr::cust(when.condition.to_sql()).into();
335				let then_expr = annotation_value_to_safe_expr(&when.then);
336				case = case.when(cond_expr, then_expr);
337			}
338			if let Some(default_val) = default {
339				case = case.else_result(annotation_value_to_safe_expr(default_val));
340			}
341			SimpleExpr::from(case)
342		}
343		AnnotExpr::Coalesce(values) => {
344			let exprs: Vec<SimpleExpr> = values.iter().map(annotation_value_to_safe_expr).collect();
345			if exprs.is_empty() {
346				Expr::val(Option::<String>::None).into()
347			} else {
348				let placeholders = vec!["?"; exprs.len()].join(", ");
349				Expr::cust_with_values(format!("COALESCE({placeholders})"), exprs).into()
350			}
351		}
352	}
353}
354
355/// Escape SQL LIKE wildcard characters in user input
356fn escape_like_pattern(input: &str) -> String {
357	input
358		.replace('\\', "\\\\")
359		.replace('%', "\\%")
360		.replace('_', "\\_")
361}
362
363/// Build a SimpleExpr from a single Filter
364#[doc(hidden)]
365pub fn build_single_filter_expr(filter: &Filter) -> Option<SimpleExpr> {
366	let col = filter.lhs_expr();
367	let lhs_sql = filter.lhs_sql();
368
369	let expr = match (&filter.operator, &filter.value) {
370		// Null handling (must come before generic patterns)
371		(FilterOperator::Eq, FilterValue::Null) => col.is_null(),
372		(FilterOperator::Ne, FilterValue::Null) => col.is_not_null(),
373		(FilterOperator::IExact, FilterValue::String(s)) => {
374			col.binary(BinOper::ILike, SimpleExpr::from(s.clone()))
375		}
376		(FilterOperator::IExact, v) => col.eq(filter_value_to_sea_value(v)),
377
378		// FieldRef: Column-to-column comparisons
379		(FilterOperator::Eq, FilterValue::FieldRef(f)) => col.eq(Expr::col(Alias::new(&f.field))),
380		(FilterOperator::Ne, FilterValue::FieldRef(f)) => col.ne(Expr::col(Alias::new(&f.field))),
381		(FilterOperator::Gt, FilterValue::FieldRef(f)) => col.gt(Expr::col(Alias::new(&f.field))),
382		(FilterOperator::Gte, FilterValue::FieldRef(f)) => col.gte(Expr::col(Alias::new(&f.field))),
383		(FilterOperator::Lt, FilterValue::FieldRef(f)) => col.lt(Expr::col(Alias::new(&f.field))),
384		(FilterOperator::Lte, FilterValue::FieldRef(f)) => col.lte(Expr::col(Alias::new(&f.field))),
385
386		// OuterRef: Correlated subquery references (use type-safe column API)
387		(FilterOperator::Eq, FilterValue::OuterRef(outer)) => {
388			col.eq(Expr::col(Alias::new(&outer.field)))
389		}
390		(FilterOperator::Ne, FilterValue::OuterRef(outer)) => {
391			col.ne(Expr::col(Alias::new(&outer.field)))
392		}
393		(FilterOperator::Gt, FilterValue::OuterRef(outer)) => {
394			col.gt(Expr::col(Alias::new(&outer.field)))
395		}
396		(FilterOperator::Gte, FilterValue::OuterRef(outer)) => {
397			col.gte(Expr::col(Alias::new(&outer.field)))
398		}
399		(FilterOperator::Lt, FilterValue::OuterRef(outer)) => {
400			col.lt(Expr::col(Alias::new(&outer.field)))
401		}
402		(FilterOperator::Lte, FilterValue::OuterRef(outer)) => {
403			col.lte(Expr::col(Alias::new(&outer.field)))
404		}
405
406		// Expression: Arithmetic expressions (validate field names before building SQL)
407		(FilterOperator::Eq, FilterValue::Expression(expr)) => {
408			col.eq(annotation_expr_to_safe_expr(expr))
409		}
410		(FilterOperator::Ne, FilterValue::Expression(expr)) => {
411			col.ne(annotation_expr_to_safe_expr(expr))
412		}
413		(FilterOperator::Gt, FilterValue::Expression(expr)) => {
414			col.gt(annotation_expr_to_safe_expr(expr))
415		}
416		(FilterOperator::Gte, FilterValue::Expression(expr)) => {
417			col.gte(annotation_expr_to_safe_expr(expr))
418		}
419		(FilterOperator::Lt, FilterValue::Expression(expr)) => {
420			col.lt(annotation_expr_to_safe_expr(expr))
421		}
422		(FilterOperator::Lte, FilterValue::Expression(expr)) => {
423			col.lte(annotation_expr_to_safe_expr(expr))
424		}
425
426		// Generic scalar value patterns
427		(FilterOperator::Eq, v) => col.eq(filter_value_to_sea_value(v)),
428		(FilterOperator::Ne, v) => col.ne(filter_value_to_sea_value(v)),
429		(FilterOperator::Gt, v) => col.gt(filter_value_to_sea_value(v)),
430		(FilterOperator::Gte, v) => col.gte(filter_value_to_sea_value(v)),
431		(FilterOperator::Lt, v) => col.lt(filter_value_to_sea_value(v)),
432		(FilterOperator::Lte, v) => col.lte(filter_value_to_sea_value(v)),
433
434		// String-specific operators
435		(FilterOperator::Contains, FilterValue::String(s)) => {
436			col.like(format!("%{}%", escape_like_pattern(s)))
437		}
438		(FilterOperator::IContains, FilterValue::String(s)) => col.binary(
439			BinOper::ILike,
440			SimpleExpr::from(format!("%{}%", escape_like_pattern(s))),
441		),
442		(FilterOperator::StartsWith, FilterValue::String(s)) => {
443			col.like(format!("{}%", escape_like_pattern(s)))
444		}
445		(FilterOperator::IStartsWith, FilterValue::String(s)) => col.binary(
446			BinOper::ILike,
447			SimpleExpr::from(format!("{}%", escape_like_pattern(s))),
448		),
449		(FilterOperator::EndsWith, FilterValue::String(s)) => {
450			col.like(format!("%{}", escape_like_pattern(s)))
451		}
452		(FilterOperator::IEndsWith, FilterValue::String(s)) => col.binary(
453			BinOper::ILike,
454			SimpleExpr::from(format!("%{}", escape_like_pattern(s))),
455		),
456		(FilterOperator::Regex, FilterValue::String(pattern)) => {
457			Expr::cust_with_values(format!("{} ~ ?", lhs_sql), [pattern.clone()]).into()
458		}
459		(FilterOperator::IRegex, FilterValue::String(pattern)) => {
460			Expr::cust_with_values(format!("{} ~* ?", lhs_sql), [pattern.clone()]).into()
461		}
462		(FilterOperator::Range, FilterValue::Range(start, end)) => Expr::cust_with_values(
463			format!("{} BETWEEN ? AND ?", lhs_sql),
464			[
465				filter_value_to_sea_value(start),
466				filter_value_to_sea_value(end),
467			],
468		)
469		.into(),
470		// Array-based In/NotIn: convert each element to a Value
471		(FilterOperator::In, FilterValue::Array(arr)) => {
472			if arr.is_empty() {
473				return None;
474			}
475			let values: Vec<Value> = arr.iter().map(|v| v.as_str().into_value()).collect();
476			col.is_in(values)
477		}
478		(FilterOperator::NotIn, FilterValue::Array(arr)) => {
479			if arr.is_empty() {
480				return None;
481			}
482			let values: Vec<Value> = arr.iter().map(|v| v.as_str().into_value()).collect();
483			col.is_not_in(values)
484		}
485		(FilterOperator::In, FilterValue::List(values)) => {
486			if values.is_empty() {
487				return None;
488			}
489			col.is_in(
490				values
491					.iter()
492					.map(filter_value_to_sea_value)
493					.collect::<Vec<_>>(),
494			)
495		}
496		(FilterOperator::NotIn, FilterValue::List(values)) => {
497			if values.is_empty() {
498				return None;
499			}
500			col.is_not_in(
501				values
502					.iter()
503					.map(filter_value_to_sea_value)
504					.collect::<Vec<_>>(),
505			)
506		}
507
508		(FilterOperator::In, FilterValue::String(s)) => {
509			let values: Vec<Value> = s.split(',').map(|v| v.trim().into_value()).collect();
510			col.is_in(values)
511		}
512		(FilterOperator::NotIn, FilterValue::String(s)) => {
513			let values: Vec<Value> = s.split(',').map(|v| v.trim().into_value()).collect();
514			col.is_not_in(values)
515		}
516
517		// Skip unsupported combinations
518		_ => return None,
519	};
520
521	Some(expr)
522}
523
524/// Build Condition from filters (AND logic only)
525#[doc(hidden)]
526pub fn build_filter_condition(filters: &[Filter]) -> Option<Condition> {
527	if filters.is_empty() {
528		return None;
529	}
530
531	let mut condition = Condition::all();
532	let mut added = false;
533
534	for filter in filters {
535		if let Some(expr) = build_single_filter_expr(filter) {
536			condition = condition.add(expr);
537			added = true;
538		}
539	}
540
541	if added { Some(condition) } else { None }
542}
543
544pub(crate) fn build_object_scope_condition(filters: &[Filter]) -> AdminResult<Option<Condition>> {
545	if filters.is_empty() {
546		return Ok(None);
547	}
548
549	let mut condition = Condition::all();
550	for filter in filters {
551		let expression = build_single_filter_expr(filter).ok_or_else(|| {
552			AdminError::ValidationError("Unsupported object permission filter".to_string())
553		})?;
554		condition = condition.add(expression);
555	}
556	Ok(Some(condition))
557}
558
559/// Maximum recursion depth for filter conditions to prevent stack overflow
560#[doc(hidden)]
561pub const MAX_FILTER_DEPTH: usize = 100;
562
563/// Build Condition from FilterCondition (supports AND/OR logic)
564///
565/// This function recursively processes FilterCondition to build complex
566/// query conditions with nested AND/OR logic.
567///
568/// # Stack Overflow Protection
569///
570/// To prevent stack overflow with deeply nested filter conditions, this function
571/// limits recursion depth to `MAX_FILTER_DEPTH` (100 levels). If the depth limit
572/// is exceeded, the function returns an error.
573#[doc(hidden)]
574pub fn build_composite_filter_condition(
575	filter_condition: &FilterCondition,
576) -> AdminResult<Option<Condition>> {
577	build_composite_filter_condition_with_depth(filter_condition, 0)
578}
579
580/// Internal helper for building composite filter conditions with depth tracking
581#[doc(hidden)]
582pub fn build_composite_filter_condition_with_depth(
583	filter_condition: &FilterCondition,
584	depth: usize,
585) -> AdminResult<Option<Condition>> {
586	// Prevent stack overflow by limiting recursion depth
587	if depth >= MAX_FILTER_DEPTH {
588		return Err(AdminError::ValidationError(format!(
589			"Filter condition exceeded maximum depth of {} levels",
590			MAX_FILTER_DEPTH
591		)));
592	}
593
594	match filter_condition {
595		FilterCondition::Single(filter) => {
596			Ok(build_single_filter_expr(filter).map(|expr| Condition::all().add(expr)))
597		}
598		FilterCondition::And(conditions) => {
599			if conditions.is_empty() {
600				return Ok(None);
601			}
602			let mut and_condition = Condition::all();
603			let mut added = false;
604			for cond in conditions {
605				if let Some(sub_cond) =
606					build_composite_filter_condition_with_depth(cond, depth + 1)?
607				{
608					and_condition = and_condition.add(sub_cond);
609					added = true;
610				}
611			}
612			// Return None if all sub-conditions were unsupported,
613			// preventing an empty Condition::all() that produces WHERE TRUE
614			if added {
615				Ok(Some(and_condition))
616			} else {
617				Ok(None)
618			}
619		}
620		FilterCondition::Or(conditions) => {
621			if conditions.is_empty() {
622				return Ok(None);
623			}
624			let mut or_condition = Condition::any();
625			let mut added = false;
626			for cond in conditions {
627				if let Some(sub_cond) =
628					build_composite_filter_condition_with_depth(cond, depth + 1)?
629				{
630					or_condition = or_condition.add(sub_cond);
631					added = true;
632				}
633			}
634			// Return None if all sub-conditions were unsupported,
635			// preventing an empty Condition::any() that produces WHERE FALSE
636			if added {
637				Ok(Some(or_condition))
638			} else {
639				Ok(None)
640			}
641		}
642		FilterCondition::Not(inner) => Ok(build_composite_filter_condition_with_depth(
643			inner,
644			depth + 1,
645		)?
646		.map(|inner_cond| inner_cond.not())),
647	}
648}
649
650fn build_combined_filter_condition(
651	filter_condition: Option<&FilterCondition>,
652	additional_filters: &[Filter],
653) -> AdminResult<(Condition, bool)> {
654	let mut combined = Condition::all();
655
656	if let Some(fc) = filter_condition
657		&& let Some(cond) = build_composite_filter_condition(fc)?
658	{
659		combined = combined.add(cond);
660	}
661
662	if let Some(simple_cond) = build_filter_condition(additional_filters) {
663		combined = combined.add(simple_cond);
664	}
665
666	Ok((
667		combined,
668		!additional_filters.is_empty() || filter_condition.is_some(),
669	))
670}
671
672fn extract_admin_list_total_count(
673	map: &serde_json::Map<String, serde_json::Value>,
674) -> AdminResult<u64> {
675	let count_value = map.get(ADMIN_LIST_TOTAL_COUNT_ALIAS).ok_or_else(|| {
676		AdminError::DatabaseError(format!(
677			"Admin list query result missing '{}' key",
678			ADMIN_LIST_TOTAL_COUNT_ALIAS
679		))
680	})?;
681
682	if let Some(count) = count_value.as_u64() {
683		return Ok(count);
684	}
685
686	count_value
687		.as_i64()
688		.and_then(|count| if count >= 0 { Some(count as u64) } else { None })
689		.ok_or_else(|| {
690			AdminError::DatabaseError(format!(
691				"Admin list query returned invalid total count: {}",
692				count_value
693			))
694		})
695}
696
697/// Admin database interface
698///
699/// Provides CRUD operations for admin panel, leveraging reinhardt-orm.
700///
701/// # Examples
702///
703/// ```
704/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
705/// use reinhardt_db::orm::DatabaseConnection;
706///
707/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
708/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
709/// let db = AdminDatabase::new(conn);
710///
711/// // List items with filters
712/// let items = db.list::<AdminRecord>("admin_records", vec![], 0, 50).await?;
713/// # Ok(())
714/// # }
715/// ```
716#[injectable(scope = Singleton, prebuilt = true)]
717#[derive(Clone)]
718pub struct AdminDatabase {
719	connection: Arc<DatabaseConnection>,
720}
721
722/// Provider key for the admin database dependency.
723#[reinhardt_di::injectable_key]
724pub struct AdminDatabaseKey;
725
726impl AdminDatabase {
727	/// Create a new admin database interface
728	///
729	/// This method accepts a DatabaseConnection directly without requiring `Arc` wrapping.
730	/// The `Arc` wrapping is handled internally for you.
731	pub fn new(connection: DatabaseConnection) -> Self {
732		Self {
733			connection: Arc::new(connection),
734		}
735	}
736
737	/// Create a new admin database interface from an Arc-wrapped connection
738	///
739	/// This is provided for cases where you already have an `Arc<DatabaseConnection>`.
740	/// In most cases, you should use `new()` instead.
741	pub fn from_arc(connection: Arc<DatabaseConnection>) -> Self {
742		Self { connection }
743	}
744
745	/// Get a reference to the underlying database connection
746	pub fn connection(&self) -> &DatabaseConnection {
747		&self.connection
748	}
749
750	/// Get a cloned Arc of the connection (for cases where you need ownership)
751	///
752	/// In most cases, you should use `connection()` instead to get a reference.
753	pub fn connection_arc(&self) -> Arc<DatabaseConnection> {
754		Arc::clone(&self.connection)
755	}
756
757	/// List items with filters, ordering, and pagination
758	///
759	/// # Examples
760	///
761	/// ```
762	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
763	/// use reinhardt_db::orm::{DatabaseConnection, Filter, FilterOperator, FilterValue};
764	///
765	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
766	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
767	/// let db = AdminDatabase::new(conn);
768	///
769	/// let filters = vec![
770	///     Filter::new("is_active".to_string(), FilterOperator::Eq, FilterValue::Boolean(true))
771	/// ];
772	///
773	/// let items = db.list::<AdminRecord>("admin_records", filters, 0, 50).await?;
774	/// # Ok(())
775	/// # }
776	/// ```
777	pub async fn list<M: Model>(
778		&self,
779		table_name: &str,
780		filters: Vec<Filter>,
781		offset: u64,
782		limit: u64,
783	) -> AdminResult<Vec<HashMap<String, serde_json::Value>>> {
784		// SELECT * is intentional: admin panel operates on dynamic schemas where
785		// the column set is not known at compile time. Each ModelAdmin defines
786		// list_display fields, and column filtering is applied at the application
787		// layer after fetching all columns.
788		let mut query = Query::select()
789			.from(Alias::new(table_name))
790			.column(ColumnRef::Asterisk)
791			.to_owned();
792
793		// Apply filters using build_filter_condition helper
794		if let Some(condition) = build_filter_condition(&filters) {
795			query.cond_where(condition);
796		}
797
798		// Apply pagination
799		query.limit(limit).offset(offset);
800
801		// Execute query
802		let (sql, values) = query.build(PostgresQueryBuilder);
803		let params = convert_values(values);
804		let rows = self
805			.connection
806			.query(&sql, params)
807			.await
808			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
809
810		// Convert QueryRow to HashMap
811		Ok(rows
812			.into_iter()
813			.filter_map(|row| {
814				// row.data is already a serde_json::Value, typically an Object
815				if let serde_json::Value::Object(map) = row.data {
816					Some(
817						map.into_iter()
818							.collect::<HashMap<String, serde_json::Value>>(),
819					)
820				} else {
821					None
822				}
823			})
824			.collect())
825	}
826
827	/// List items with composite filter conditions (supports AND/OR logic)
828	///
829	/// This method supports complex filter conditions using FilterCondition,
830	/// which allows building nested AND/OR queries.
831	///
832	/// # Arguments
833	///
834	/// * `table_name` - The name of the table to query
835	/// * `filter_condition` - Optional composite filter condition (AND/OR logic)
836	/// * `additional_filters` - Additional simple filters to AND with the condition
837	/// * `sort_by` - Optional sort field (prefix with "-" for descending, e.g., "created_at" or "-created_at")
838	/// * `offset` - Number of items to skip for pagination
839	/// * `limit` - Maximum number of items to return
840	pub async fn list_with_condition<M: Model>(
841		&self,
842		table_name: &str,
843		filter_condition: Option<&FilterCondition>,
844		additional_filters: Vec<Filter>,
845		sort_by: Option<&str>,
846		offset: u64,
847		limit: u64,
848	) -> AdminResult<Vec<HashMap<String, serde_json::Value>>> {
849		// SELECT * is intentional: admin panel operates on dynamic schemas where
850		// the column set is not known at compile time. Each ModelAdmin defines
851		// list_display fields, and column filtering is applied at the application
852		// layer after fetching all columns.
853		let mut query = Query::select()
854			.from(Alias::new(table_name))
855			.column(ColumnRef::Asterisk)
856			.to_owned();
857
858		let (combined, has_filter) =
859			build_combined_filter_condition(filter_condition, &additional_filters)?;
860
861		if has_filter {
862			query.cond_where(combined);
863		}
864
865		// Apply sorting (if specified)
866		if let Some(sort_str) = sort_by {
867			let (field, is_desc) = if let Some(stripped) = sort_str.strip_prefix('-') {
868				(stripped, true)
869			} else {
870				(sort_str, false)
871			};
872
873			let col = Alias::new(field);
874			if is_desc {
875				query.order_by(col, Order::Desc);
876			} else {
877				query.order_by(col, Order::Asc);
878			}
879		}
880
881		// Apply pagination
882		query.limit(limit).offset(offset);
883
884		// Execute query
885		let (sql, values) = query.build(PostgresQueryBuilder);
886		let params = convert_values(values);
887		let rows = self
888			.connection
889			.query(&sql, params)
890			.await
891			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
892
893		// Convert QueryRow to HashMap
894		Ok(rows
895			.into_iter()
896			.filter_map(|row| {
897				if let serde_json::Value::Object(map) = row.data {
898					Some(
899						map.into_iter()
900							.filter(|(key, _)| !SENSITIVE_FIELDS.contains(&key.as_str()))
901							.collect::<HashMap<String, serde_json::Value>>(),
902					)
903				} else {
904					None
905				}
906			})
907			.collect())
908	}
909
910	/// List items and return the filtered total count with one query for non-empty pages.
911	///
912	/// This uses a windowed `COUNT(*) OVER()` expression so the admin list endpoint
913	/// can fetch page rows and pagination metadata without issuing a separate count
914	/// query on the common path.
915	pub async fn list_with_condition_and_count<M: Model>(
916		&self,
917		table_name: &str,
918		filter_condition: Option<&FilterCondition>,
919		additional_filters: Vec<Filter>,
920		sort_by: Option<&str>,
921		offset: u64,
922		limit: u64,
923	) -> AdminResult<(Vec<HashMap<String, serde_json::Value>>, u64)> {
924		// SELECT * is intentional: admin panel operates on dynamic schemas where
925		// the column set is not known at compile time. The synthetic total-count
926		// column is removed before returning API rows.
927		let mut query = Query::select()
928			.from(Alias::new(table_name))
929			.column(ColumnRef::Asterisk)
930			.expr_as(
931				Expr::cust("COUNT(*) OVER()"),
932				Alias::new(ADMIN_LIST_TOTAL_COUNT_ALIAS),
933			)
934			.to_owned();
935
936		let (combined, has_filter) =
937			build_combined_filter_condition(filter_condition, &additional_filters)?;
938
939		if has_filter {
940			query.cond_where(combined);
941		}
942
943		if let Some(sort_str) = sort_by {
944			let (field, is_desc) = if let Some(stripped) = sort_str.strip_prefix('-') {
945				(stripped, true)
946			} else {
947				(sort_str, false)
948			};
949
950			let col = Alias::new(field);
951			if is_desc {
952				query.order_by(col, Order::Desc);
953			} else {
954				query.order_by(col, Order::Asc);
955			}
956		}
957
958		query.limit(limit).offset(offset);
959
960		let (sql, values) = query.build(PostgresQueryBuilder);
961		let params = convert_values(values);
962		let rows = self
963			.connection
964			.query(&sql, params)
965			.await
966			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
967
968		if rows.is_empty() {
969			if offset == 0 && limit > 0 {
970				return Ok((Vec::new(), 0));
971			}
972
973			let count = self
974				.count_with_condition::<M>(table_name, filter_condition, additional_filters)
975				.await?;
976			return Ok((Vec::new(), count));
977		}
978
979		let mut total_count = None;
980		let results = rows
981			.into_iter()
982			.filter_map(|row| {
983				if let serde_json::Value::Object(mut map) = row.data {
984					if total_count.is_none() {
985						total_count = Some(extract_admin_list_total_count(&map));
986					}
987
988					map.remove(ADMIN_LIST_TOTAL_COUNT_ALIAS);
989
990					Some(
991						map.into_iter()
992							.filter(|(key, _)| !SENSITIVE_FIELDS.contains(&key.as_str()))
993							.collect::<HashMap<String, serde_json::Value>>(),
994					)
995				} else {
996					None
997				}
998			})
999			.collect::<Vec<_>>();
1000
1001		let total_count = total_count.unwrap_or_else(|| {
1002			Err(AdminError::DatabaseError(
1003				"Admin list query returned no object rows".to_string(),
1004			))
1005		})?;
1006
1007		Ok((results, total_count))
1008	}
1009
1010	/// Count items with composite filter conditions (supports AND/OR logic)
1011	///
1012	/// # Arguments
1013	///
1014	/// * `table_name` - The name of the table to query
1015	/// * `filter_condition` - Optional composite filter condition (AND/OR logic)
1016	/// * `additional_filters` - Additional simple filters to AND with the condition
1017	pub async fn count_with_condition<M: Model>(
1018		&self,
1019		table_name: &str,
1020		filter_condition: Option<&FilterCondition>,
1021		additional_filters: Vec<Filter>,
1022	) -> AdminResult<u64> {
1023		let mut query = Query::select()
1024			.from(Alias::new(table_name))
1025			.expr(Expr::cust("COUNT(*) AS count"))
1026			.to_owned();
1027
1028		let (combined, has_filter) =
1029			build_combined_filter_condition(filter_condition, &additional_filters)?;
1030
1031		if has_filter {
1032			query.cond_where(combined);
1033		}
1034
1035		let (sql, values) = query.build(PostgresQueryBuilder);
1036		let params = convert_values(values);
1037		let row = self
1038			.connection
1039			.query_one(&sql, params)
1040			.await
1041			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1042
1043		// Extract count from result, propagating errors for unexpected formats
1044		let count = extract_count_from_row(&row.data)?;
1045
1046		Ok(count)
1047	}
1048
1049	/// Get a single item by ID
1050	///
1051	/// # Examples
1052	///
1053	/// ```
1054	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1055	/// use reinhardt_db::orm::DatabaseConnection;
1056	///
1057	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1058	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1059	/// let db = AdminDatabase::new(conn);
1060	///
1061	/// let item = db.get::<AdminRecord>("admin_records", "id", "1").await?;
1062	/// # Ok(())
1063	/// # }
1064	/// ```
1065	pub async fn get<M: Model>(
1066		&self,
1067		table_name: &str,
1068		pk_field: &str,
1069		id: &str,
1070	) -> AdminResult<Option<HashMap<String, serde_json::Value>>> {
1071		self.get_with_filters::<M>(table_name, pk_field, id, Vec::new())
1072			.await
1073	}
1074
1075	/// Get a single item by ID within additional object-level filters.
1076	pub async fn get_with_filters<M: Model>(
1077		&self,
1078		table_name: &str,
1079		pk_field: &str,
1080		id: &str,
1081		filters: Vec<Filter>,
1082	) -> AdminResult<Option<HashMap<String, serde_json::Value>>> {
1083		let pk_value = parse_pk_value(table_name, pk_field, id);
1084
1085		// SELECT * is intentional: admin detail view displays all fields from the
1086		// model. The admin panel operates on dynamic schemas where the column set
1087		// is determined by the ModelAdmin configuration at runtime.
1088		let mut query = Query::select()
1089			.from(Alias::new(table_name))
1090			.column(ColumnRef::Asterisk)
1091			.and_where(Expr::col(Alias::new(pk_field)).eq(pk_value))
1092			.to_owned();
1093		if let Some(condition) = build_object_scope_condition(&filters)? {
1094			query.cond_where(condition);
1095		}
1096
1097		let (sql, values) = query.build(PostgresQueryBuilder);
1098		let params = convert_values(values);
1099		let row = self
1100			.connection
1101			.query_optional(&sql, params)
1102			.await
1103			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1104
1105		Ok(row.and_then(|r| {
1106			// r.data is already a serde_json::Value, typically an Object
1107			if let serde_json::Value::Object(map) = r.data {
1108				Some(
1109					map.into_iter()
1110						.collect::<HashMap<String, serde_json::Value>>(),
1111				)
1112			} else {
1113				None
1114			}
1115		}))
1116	}
1117
1118	/// Create a new item
1119	///
1120	/// # Examples
1121	///
1122	/// ```
1123	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1124	/// use reinhardt_db::orm::DatabaseConnection;
1125	/// use std::collections::HashMap;
1126	///
1127	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1128	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1129	/// let db = AdminDatabase::new(conn);
1130	///
1131	/// let mut data = HashMap::new();
1132	/// data.insert("name".to_string(), serde_json::json!("Alice"));
1133	/// data.insert("email".to_string(), serde_json::json!("alice@example.com"));
1134	///
1135	/// db.create::<AdminRecord>("admin_records", Some("id"), data).await?;
1136	/// # Ok(())
1137	/// # }
1138	/// ```
1139	pub async fn create<M: Model>(
1140		&self,
1141		table_name: &str,
1142		pk_field: Option<&str>,
1143		data: HashMap<String, serde_json::Value>,
1144	) -> AdminResult<u64> {
1145		let pk_field = pk_field.unwrap_or("id");
1146		let mut query = Query::insert()
1147			.into_table(Alias::new(table_name))
1148			.to_owned();
1149
1150		// Sort keys for deterministic column ordering in generated SQL.
1151		// HashMap iteration order is non-deterministic, which causes
1152		// flaky tests and non-reproducible query plans.
1153		let mut sorted_keys: Vec<String> = data.keys().cloned().collect();
1154		sorted_keys.sort();
1155
1156		// Build column and value lists in sorted order
1157		let mut columns = Vec::new();
1158		let mut values = Vec::new();
1159
1160		for key in sorted_keys {
1161			let value = data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
1162			columns.push(Alias::new(&key));
1163
1164			let sea_value = json_to_sea_value(value);
1165			values.push(sea_value);
1166		}
1167
1168		// Pass values directly for reinhardt-query
1169		query.columns(columns).values(values).map_err(|e| {
1170			AdminError::DatabaseError(format!("column/value count mismatch: {}", e))
1171		})?;
1172
1173		// Add RETURNING clause using the actual primary key field
1174		query.returning([Alias::new(pk_field)]);
1175
1176		let (sql, values) = query.build(PostgresQueryBuilder);
1177		let params = convert_values(values);
1178		let row = self
1179			.connection
1180			.query_one(&sql, params)
1181			.await
1182			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1183
1184		// Extract the ID from the returned row using the primary key field
1185		match row.data.get(pk_field) {
1186			Some(serde_json::Value::Number(n)) => n.as_u64().ok_or_else(|| {
1187				AdminError::DatabaseError(format!(
1188					"RETURNING clause for '{}' returned non-unsigned-integer: {}",
1189					pk_field, n
1190				))
1191			}),
1192			Some(serde_json::Value::String(_)) => {
1193				// UUID and other string-based PKs: return 1 as affected count
1194				// (the actual PK value is a string, not representable as u64)
1195				Ok(1)
1196			}
1197			_ => Err(AdminError::DatabaseError(format!(
1198				"RETURNING clause did not return expected primary key field '{}'",
1199				pk_field
1200			))),
1201		}
1202	}
1203
1204	/// Update an existing item
1205	///
1206	/// # Examples
1207	///
1208	/// ```
1209	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1210	/// use reinhardt_db::orm::DatabaseConnection;
1211	/// use std::collections::HashMap;
1212	///
1213	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1214	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1215	/// let db = AdminDatabase::new(conn);
1216	///
1217	/// let mut data = HashMap::new();
1218	/// data.insert("name".to_string(), serde_json::json!("Alice Updated"));
1219	///
1220	/// db.update::<AdminRecord>("admin_records", "id", "1", data).await?;
1221	/// # Ok(())
1222	/// # }
1223	/// ```
1224	pub async fn update<M: Model>(
1225		&self,
1226		table_name: &str,
1227		pk_field: &str,
1228		id: &str,
1229		data: HashMap<String, serde_json::Value>,
1230	) -> AdminResult<u64> {
1231		self.update_with_filters::<M>(table_name, pk_field, id, data, Vec::new())
1232			.await
1233	}
1234
1235	/// Update an item by ID within additional object-level filters.
1236	pub async fn update_with_filters<M: Model>(
1237		&self,
1238		table_name: &str,
1239		pk_field: &str,
1240		id: &str,
1241		data: HashMap<String, serde_json::Value>,
1242		filters: Vec<Filter>,
1243	) -> AdminResult<u64> {
1244		let mut query = Query::update().table(Alias::new(table_name)).to_owned();
1245
1246		// Sort keys for deterministic SET clause ordering in generated SQL
1247		let mut sorted_keys: Vec<String> = data.keys().cloned().collect();
1248		sorted_keys.sort();
1249
1250		// Build SET clauses in sorted order
1251		for key in sorted_keys {
1252			let value = data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
1253			let sea_value = json_to_sea_value(value);
1254			query.value(Alias::new(&key), sea_value);
1255		}
1256
1257		let pk_value = parse_pk_value(table_name, pk_field, id);
1258		query.and_where(Expr::col(Alias::new(pk_field)).eq(pk_value));
1259		if let Some(condition) = build_object_scope_condition(&filters)? {
1260			query.cond_where(condition);
1261		}
1262
1263		let (sql, values) = query.build(PostgresQueryBuilder);
1264		let params = convert_values(values);
1265		let affected = self
1266			.connection
1267			.execute(&sql, params)
1268			.await
1269			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1270
1271		Ok(affected)
1272	}
1273
1274	/// Delete an item by ID
1275	///
1276	/// # Examples
1277	///
1278	/// ```
1279	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1280	/// use reinhardt_db::orm::DatabaseConnection;
1281	///
1282	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1283	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1284	/// let db = AdminDatabase::new(conn);
1285	///
1286	/// db.delete::<AdminRecord>("admin_records", "id", "1").await?;
1287	/// # Ok(())
1288	/// # }
1289	/// ```
1290	pub async fn delete<M: Model>(
1291		&self,
1292		table_name: &str,
1293		pk_field: &str,
1294		id: &str,
1295	) -> AdminResult<u64> {
1296		self.delete_with_filters::<M>(table_name, pk_field, id, Vec::new())
1297			.await
1298	}
1299
1300	/// Delete an item by ID within additional object-level filters.
1301	pub async fn delete_with_filters<M: Model>(
1302		&self,
1303		table_name: &str,
1304		pk_field: &str,
1305		id: &str,
1306		filters: Vec<Filter>,
1307	) -> AdminResult<u64> {
1308		let pk_value = parse_pk_value(table_name, pk_field, id);
1309
1310		let mut query = Query::delete()
1311			.from_table(Alias::new(table_name))
1312			.and_where(Expr::col(Alias::new(pk_field)).eq(pk_value))
1313			.to_owned();
1314		if let Some(condition) = build_object_scope_condition(&filters)? {
1315			query.cond_where(condition);
1316		}
1317
1318		let (sql, values) = query.build(PostgresQueryBuilder);
1319		let params = convert_values(values);
1320		let affected = self
1321			.connection
1322			.execute(&sql, params)
1323			.await
1324			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1325
1326		Ok(affected)
1327	}
1328
1329	/// Delete multiple items by IDs (bulk delete)
1330	///
1331	/// # Examples
1332	///
1333	/// ```
1334	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1335	/// use reinhardt_db::orm::DatabaseConnection;
1336	///
1337	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1338	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1339	/// let db = AdminDatabase::new(conn);
1340	///
1341	/// let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()];
1342	/// db.bulk_delete::<AdminRecord>("admin_records", "id", ids).await?;
1343	/// # Ok(())
1344	/// # }
1345	/// ```
1346	pub async fn bulk_delete<M: Model>(
1347		&self,
1348		table_name: &str,
1349		pk_field: &str,
1350		ids: Vec<String>,
1351	) -> AdminResult<u64> {
1352		self.bulk_delete_by_table(table_name, pk_field, ids).await
1353	}
1354
1355	/// Delete multiple items by ID within additional object-level filters.
1356	pub async fn bulk_delete_with_filters<M: Model>(
1357		&self,
1358		table_name: &str,
1359		pk_field: &str,
1360		ids: Vec<String>,
1361		filters: Vec<Filter>,
1362	) -> AdminResult<u64> {
1363		if ids.is_empty() {
1364			return Ok(0);
1365		}
1366		let pk_values = parse_pk_values(table_name, pk_field, &ids);
1367		let mut query = Query::delete()
1368			.from_table(Alias::new(table_name))
1369			.and_where(Expr::col(Alias::new(pk_field)).is_in(pk_values))
1370			.to_owned();
1371		if let Some(condition) = build_object_scope_condition(&filters)? {
1372			query.cond_where(condition);
1373		}
1374		let (sql, values) = query.build(PostgresQueryBuilder);
1375		let params = convert_values(values);
1376		self.connection
1377			.execute(&sql, params)
1378			.await
1379			.map_err(|error| AdminError::DatabaseError(error.to_string()))
1380	}
1381
1382	/// Delete multiple items by IDs without requiring Model type parameter
1383	///
1384	/// This method provides a type-safe way to perform bulk deletions without
1385	/// requiring a Model type parameter. It's particularly useful for admin actions
1386	/// where the model type may not be known at compile time.
1387	///
1388	/// # Examples
1389	///
1390	/// ```
1391	/// use reinhardt_admin::core::AdminDatabase;
1392	/// use reinhardt_db::orm::DatabaseConnection;
1393	///
1394	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1395	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1396	/// let db = AdminDatabase::new(conn);
1397	///
1398	/// let ids = vec!["1".to_string(), "2".to_string(), "3".to_string()];
1399	/// db.bulk_delete_by_table("users", "id", ids).await?;
1400	/// # Ok(())
1401	/// # }
1402	/// ```
1403	pub async fn bulk_delete_by_table(
1404		&self,
1405		table_name: &str,
1406		pk_field: &str,
1407		ids: Vec<String>,
1408	) -> AdminResult<u64> {
1409		if ids.is_empty() {
1410			return Ok(0);
1411		}
1412
1413		let pk_values = parse_pk_values(table_name, pk_field, &ids);
1414
1415		let query = Query::delete()
1416			.from_table(Alias::new(table_name))
1417			.and_where(Expr::col(Alias::new(pk_field)).is_in(pk_values))
1418			.to_owned();
1419
1420		let (sql, values) = query.build(PostgresQueryBuilder);
1421		let params = convert_values(values);
1422		let affected = self
1423			.connection
1424			.execute(&sql, params)
1425			.await
1426			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1427
1428		Ok(affected)
1429	}
1430
1431	/// Count total items with optional filters
1432	///
1433	/// # Examples
1434	///
1435	/// ```
1436	/// use reinhardt_admin::core::{AdminDatabase, AdminRecord};
1437	/// use reinhardt_db::orm::{DatabaseConnection, Filter, FilterOperator, FilterValue};
1438	///
1439	/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1440	/// let conn = DatabaseConnection::connect("postgres://localhost/test").await?;
1441	/// let db = AdminDatabase::new(conn);
1442	///
1443	/// let filters = vec![
1444	///     Filter::new("is_active".to_string(), FilterOperator::Eq, FilterValue::Boolean(true))
1445	/// ];
1446	///
1447	/// let count = db.count::<AdminRecord>("admin_records", filters).await?;
1448	/// # Ok(())
1449	/// # }
1450	/// ```
1451	pub async fn count<M: Model>(
1452		&self,
1453		table_name: &str,
1454		filters: Vec<Filter>,
1455	) -> AdminResult<u64> {
1456		let mut query = Query::select()
1457			.from(Alias::new(table_name))
1458			.expr(Expr::cust("COUNT(*) AS count"))
1459			.to_owned();
1460
1461		// Apply filters using build_filter_condition helper
1462		if let Some(condition) = build_filter_condition(&filters) {
1463			query.cond_where(condition);
1464		}
1465
1466		let (sql, values) = query.build(PostgresQueryBuilder);
1467		let params = convert_values(values);
1468		let row = self
1469			.connection
1470			.query_one(&sql, params)
1471			.await
1472			.map_err(|e| AdminError::DatabaseError(e.to_string()))?;
1473
1474		// Extract count from result, propagating errors for unexpected formats
1475		let count = extract_count_from_row(&row.data)?;
1476
1477		Ok(count)
1478	}
1479}
1480
1481/// Extract count value from a query result row
1482///
1483/// Attempts to extract an integer count from the query result by looking for
1484/// a "count" key in the JSON object.
1485///
1486/// Returns an error if:
1487/// - The "count" key is missing (lists available keys for debugging)
1488/// - The "count" value is not an integer
1489/// - The data format is not a JSON object
1490#[doc(hidden)]
1491pub fn extract_count_from_row(data: &serde_json::Value) -> AdminResult<u64> {
1492	if let Some(count_value) = data.get("count") {
1493		return count_value.as_i64().map(|v| v as u64).ok_or_else(|| {
1494			AdminError::DatabaseError(format!(
1495				"COUNT query returned non-integer value: {}",
1496				count_value
1497			))
1498		});
1499	}
1500
1501	// Report available keys for diagnostics instead of using non-deterministic
1502	// HashMap iteration order to pick the first value
1503	if let Some(obj) = data.as_object() {
1504		let available_keys: Vec<&String> = obj.keys().collect();
1505		return Err(AdminError::DatabaseError(format!(
1506			"COUNT query result missing 'count' key, available keys: {:?}",
1507			available_keys
1508		)));
1509	}
1510
1511	Err(AdminError::DatabaseError(format!(
1512		"COUNT query returned unexpected data format: {}",
1513		data
1514	)))
1515}
1516
1517/// Injectable trait implementation for AdminDatabase
1518///
1519/// Auto-constructs from [`DatabaseConnection`] in the singleton scope when
1520/// no pre-built `AdminDatabase` exists. This enables admin DI dependencies
1521/// to be resolved at request time without requiring async initialization
1522/// in the synchronous `routes()` function.
1523///
1524/// Resolution order:
1525/// 1. Check singleton cache for pre-built `AdminDatabase` (backward compat)
1526/// 2. If not found, construct from `DatabaseConnection` in singleton scope
1527/// 3. Cache the constructed instance for subsequent requests
1528#[async_trait]
1529impl Injectable for AdminDatabase {
1530	async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
1531		// Check if pre-built AdminDatabase exists (backward compat with configure_di)
1532		if let Some(db) = ctx.get_singleton::<Self>() {
1533			return Ok((*db).clone());
1534		}
1535
1536		// Auto-construct from DatabaseConnection in singleton scope
1537		let conn = ctx.get_singleton::<DatabaseConnection>().ok_or_else(|| {
1538			reinhardt_di::DiError::NotRegistered {
1539				type_name: "AdminDatabase".into(),
1540				hint: "DatabaseConnection must be registered as a singleton. \
1541				       Use InjectionContextBuilder::singleton(db_connection) during setup."
1542					.into(),
1543			}
1544		})?;
1545
1546		let db = AdminDatabase::from_arc(conn);
1547		// Cache for subsequent requests
1548		ctx.set_singleton(db.clone());
1549		Ok(db)
1550	}
1551}
1552
1553#[reinhardt_di::injectable(scope = "singleton")]
1554async fn admin_database_provider(
1555	#[inject] db: AdminDatabase,
1556) -> FactoryOutput<AdminDatabaseKey, AdminDatabase> {
1557	FactoryOutput::new(db)
1558}
1559
1560// Register AdminDatabase in the global dependency registry so direct
1561// `#[inject] AdminDatabase` parameters can resolve it via ctx.resolve().
1562// Delegates to Injectable::inject() for lazy construction from DatabaseConnection.
1563fn __register_admin_database(registry: &reinhardt_di::DependencyRegistry) {
1564	registry.register::<AdminDatabase>(
1565		reinhardt_di::DependencyScope::Singleton,
1566		reinhardt_di::InjectableFactory::<AdminDatabase>::new(),
1567	);
1568}
1569
1570reinhardt_di::inventory::submit! {
1571	reinhardt_di::InjectableRegistration::new(
1572		__register_admin_database
1573	)
1574}
1575
1576#[cfg(all(test, server))]
1577mod tests {
1578	use super::*;
1579	use reinhardt_db::orm::annotation::Expression;
1580	use reinhardt_db::orm::expressions::{F, OuterRef};
1581	use rstest::rstest;
1582
1583	// ==================== escape_like_pattern tests ====================
1584
1585	#[rstest]
1586	fn test_escape_like_pattern_percent() {
1587		// Arrange
1588		let input = "100%";
1589
1590		// Act
1591		let result = escape_like_pattern(input);
1592
1593		// Assert
1594		assert_eq!(result, "100\\%");
1595	}
1596
1597	#[rstest]
1598	fn test_escape_like_pattern_underscore() {
1599		// Arrange
1600		let input = "user_name";
1601
1602		// Act
1603		let result = escape_like_pattern(input);
1604
1605		// Assert
1606		assert_eq!(result, "user\\_name");
1607	}
1608
1609	#[rstest]
1610	fn test_escape_like_pattern_backslash() {
1611		// Arrange
1612		let input = "path\\to";
1613
1614		// Act
1615		let result = escape_like_pattern(input);
1616
1617		// Assert
1618		assert_eq!(result, "path\\\\to");
1619	}
1620
1621	#[rstest]
1622	fn test_escape_like_pattern_combined() {
1623		// Arrange
1624		let input = "100%_done";
1625
1626		// Act
1627		let result = escape_like_pattern(input);
1628
1629		// Assert
1630		assert_eq!(result, "100\\%\\_done");
1631	}
1632
1633	#[rstest]
1634	fn test_escape_like_pattern_no_special_chars() {
1635		// Arrange
1636		let input = "normal text";
1637
1638		// Act
1639		let result = escape_like_pattern(input);
1640
1641		// Assert
1642		assert_eq!(result, "normal text");
1643	}
1644
1645	// ==================== escape_like_pattern regression tests (#632) ====================
1646
1647	/// Regression tests for issue #632: LIKE wildcard injection via unescaped metacharacters.
1648	/// Verifies that percent, underscore, and backslash in user input are always escaped
1649	/// so they cannot be used as LIKE wildcards or escape prefix injections.
1650	#[rstest]
1651	#[case("%wildcard%", "\\%wildcard\\%")]
1652	#[case("under_score", "under\\_score")]
1653	#[case("back\\slash", "back\\\\slash")]
1654	#[case("%_%", "\\%\\_\\%")]
1655	fn test_escape_like_pattern_sanitizes_special_chars(
1656		#[case] input: &str,
1657		#[case] expected: &str,
1658	) {
1659		// Arrange: user-supplied string containing LIKE metacharacters
1660		// Act
1661		let escaped = escape_like_pattern(input);
1662		// Assert: output exactly matches fully-escaped form with no unescaped metacharacters
1663		assert_eq!(
1664			escaped, expected,
1665			"input={input:?} was not correctly escaped"
1666		);
1667	}
1668
1669	// ==================== build_composite_filter_condition tests ====================
1670
1671	#[test]
1672	fn test_build_composite_single_condition() {
1673		let filter = Filter::new(
1674			"name".to_string(),
1675			FilterOperator::Eq,
1676			FilterValue::String("Alice".to_string()),
1677		);
1678		let condition = FilterCondition::Single(filter);
1679
1680		let result = build_composite_filter_condition(&condition);
1681
1682		assert!(result.is_ok());
1683		let result = result.unwrap();
1684		assert!(result.is_some());
1685		// The condition should produce valid SQL when used
1686		let cond = result.unwrap();
1687		let query = Query::select()
1688			.from(Alias::new("users"))
1689			.column(ColumnRef::Asterisk)
1690			.cond_where(cond)
1691			.to_string(PostgresQueryBuilder);
1692		assert!(query.contains("\"name\""));
1693		assert!(query.contains("'Alice'"));
1694	}
1695
1696	#[test]
1697	fn test_build_composite_or_condition() {
1698		let filter1 = Filter::new(
1699			"name".to_string(),
1700			FilterOperator::Contains,
1701			FilterValue::String("Alice".to_string()),
1702		);
1703		let filter2 = Filter::new(
1704			"email".to_string(),
1705			FilterOperator::Contains,
1706			FilterValue::String("alice".to_string()),
1707		);
1708
1709		let condition = FilterCondition::Or(vec![
1710			FilterCondition::Single(filter1),
1711			FilterCondition::Single(filter2),
1712		]);
1713
1714		let result = build_composite_filter_condition(&condition);
1715
1716		assert!(result.is_ok());
1717		let result = result.unwrap();
1718		assert!(result.is_some());
1719		let cond = result.unwrap();
1720		let query = Query::select()
1721			.from(Alias::new("users"))
1722			.column(ColumnRef::Asterisk)
1723			.cond_where(cond)
1724			.to_string(PostgresQueryBuilder);
1725		// OR condition should produce SQL with OR keyword
1726		assert!(query.contains("\"name\""));
1727		assert!(query.contains("\"email\""));
1728		assert!(query.contains("OR"));
1729	}
1730
1731	#[test]
1732	fn test_build_composite_and_condition() {
1733		let filter1 = Filter::new(
1734			"is_active".to_string(),
1735			FilterOperator::Eq,
1736			FilterValue::Boolean(true),
1737		);
1738		let filter2 = Filter::new(
1739			"is_staff".to_string(),
1740			FilterOperator::Eq,
1741			FilterValue::Boolean(true),
1742		);
1743
1744		let condition = FilterCondition::And(vec![
1745			FilterCondition::Single(filter1),
1746			FilterCondition::Single(filter2),
1747		]);
1748
1749		let result = build_composite_filter_condition(&condition);
1750
1751		assert!(result.is_ok());
1752		let result = result.unwrap();
1753		assert!(result.is_some());
1754		let cond = result.unwrap();
1755		let query = Query::select()
1756			.from(Alias::new("users"))
1757			.column(ColumnRef::Asterisk)
1758			.cond_where(cond)
1759			.to_string(PostgresQueryBuilder);
1760		// AND condition should produce SQL with AND keyword
1761		assert!(query.contains("\"is_active\""));
1762		assert!(query.contains("\"is_staff\""));
1763		assert!(query.contains("AND"));
1764	}
1765
1766	#[test]
1767	fn test_build_composite_nested_condition() {
1768		// Build: (name LIKE '%Alice%' OR email LIKE '%alice%') AND is_active = true
1769		let filter_name = Filter::new(
1770			"name".to_string(),
1771			FilterOperator::Contains,
1772			FilterValue::String("Alice".to_string()),
1773		);
1774		let filter_email = Filter::new(
1775			"email".to_string(),
1776			FilterOperator::Contains,
1777			FilterValue::String("alice".to_string()),
1778		);
1779		let filter_active = Filter::new(
1780			"is_active".to_string(),
1781			FilterOperator::Eq,
1782			FilterValue::Boolean(true),
1783		);
1784
1785		let or_condition = FilterCondition::Or(vec![
1786			FilterCondition::Single(filter_name),
1787			FilterCondition::Single(filter_email),
1788		]);
1789
1790		let and_condition =
1791			FilterCondition::And(vec![or_condition, FilterCondition::Single(filter_active)]);
1792
1793		let result = build_composite_filter_condition(&and_condition);
1794
1795		assert!(result.is_ok());
1796		let result = result.unwrap();
1797		assert!(result.is_some());
1798		let cond = result.unwrap();
1799		let query = Query::select()
1800			.from(Alias::new("users"))
1801			.column(ColumnRef::Asterisk)
1802			.cond_where(cond)
1803			.to_string(PostgresQueryBuilder);
1804		// Nested condition should contain both OR and AND
1805		assert!(query.contains("\"name\""));
1806		assert!(query.contains("\"email\""));
1807		assert!(query.contains("\"is_active\""));
1808		assert!(query.contains("OR"));
1809		assert!(query.contains("AND"));
1810	}
1811
1812	#[test]
1813	fn test_build_composite_empty_or() {
1814		let condition = FilterCondition::Or(vec![]);
1815
1816		let result = build_composite_filter_condition(&condition);
1817
1818		// Empty OR should return Ok(None)
1819		assert!(result.is_ok());
1820		assert!(result.unwrap().is_none());
1821	}
1822
1823	#[test]
1824	fn test_build_composite_empty_and() {
1825		let condition = FilterCondition::And(vec![]);
1826
1827		let result = build_composite_filter_condition(&condition);
1828
1829		// Empty AND should return Ok(None)
1830		assert!(result.is_ok());
1831		assert!(result.unwrap().is_none());
1832	}
1833
1834	#[test]
1835	fn test_build_composite_depth_overflow_returns_error() {
1836		// Build a filter condition that exceeds MAX_FILTER_DEPTH by nesting
1837		let base_filter = Filter::new(
1838			"name".to_string(),
1839			FilterOperator::Eq,
1840			FilterValue::String("Alice".to_string()),
1841		);
1842		let mut condition = FilterCondition::Single(base_filter);
1843		// Wrap in And() nesting MAX_FILTER_DEPTH + 1 times to exceed the limit
1844		for _ in 0..=MAX_FILTER_DEPTH {
1845			condition = FilterCondition::And(vec![condition]);
1846		}
1847
1848		let result = build_composite_filter_condition(&condition);
1849
1850		assert!(result.is_err());
1851		let err = result.unwrap_err();
1852		assert!(matches!(err, AdminError::ValidationError(_)));
1853		let err_msg = err.to_string();
1854		assert!(
1855			err_msg.contains("exceeded maximum depth"),
1856			"Error message should mention exceeded depth, got: {}",
1857			err_msg
1858		);
1859	}
1860
1861	// ==================== FieldRef/OuterRef/Expression filter tests ====================
1862
1863	#[test]
1864	fn test_build_single_filter_expr_field_ref_eq() {
1865		let filter = Filter::new(
1866			"price".to_string(),
1867			FilterOperator::Eq,
1868			FilterValue::FieldRef(F::new("discount_price")),
1869		);
1870		let result = build_single_filter_expr(&filter);
1871		assert!(result.is_some());
1872
1873		let query = Query::select()
1874			.from(Alias::new("products"))
1875			.column(ColumnRef::Asterisk)
1876			.cond_where(Condition::all().add(result.unwrap()))
1877			.to_string(PostgresQueryBuilder);
1878		assert!(query.contains("\"price\""));
1879		assert!(query.contains("\"discount_price\""));
1880	}
1881
1882	#[test]
1883	fn test_build_single_filter_expr_field_ref_gt() {
1884		let filter = Filter::new(
1885			"price".to_string(),
1886			FilterOperator::Gt,
1887			FilterValue::FieldRef(F::new("cost")),
1888		);
1889		let result = build_single_filter_expr(&filter);
1890		assert!(result.is_some());
1891	}
1892
1893	#[test]
1894	fn test_build_single_filter_expr_field_ref_all_operators() {
1895		let operators = [
1896			FilterOperator::Eq,
1897			FilterOperator::Ne,
1898			FilterOperator::Gt,
1899			FilterOperator::Gte,
1900			FilterOperator::Lt,
1901			FilterOperator::Lte,
1902		];
1903
1904		for op in operators {
1905			let filter = Filter::new(
1906				"field_a".to_string(),
1907				op.clone(),
1908				FilterValue::FieldRef(F::new("field_b")),
1909			);
1910			let result = build_single_filter_expr(&filter);
1911			assert!(
1912				result.is_some(),
1913				"FieldRef with {:?} should produce Some",
1914				op
1915			);
1916		}
1917	}
1918
1919	#[test]
1920	fn test_build_single_filter_expr_outer_ref() {
1921		let filter = Filter::new(
1922			"author_id".to_string(),
1923			FilterOperator::Eq,
1924			FilterValue::OuterRef(OuterRef::new("authors.id")),
1925		);
1926		let result = build_single_filter_expr(&filter);
1927		assert!(result.is_some());
1928
1929		let query = Query::select()
1930			.from(Alias::new("books"))
1931			.column(ColumnRef::Asterisk)
1932			.cond_where(Condition::all().add(result.unwrap()))
1933			.to_string(PostgresQueryBuilder);
1934		assert!(query.contains("author_id"));
1935		assert!(query.contains("authors.id"));
1936	}
1937
1938	#[test]
1939	fn test_build_single_filter_expr_outer_ref_all_operators() {
1940		let operators = [
1941			FilterOperator::Eq,
1942			FilterOperator::Ne,
1943			FilterOperator::Gt,
1944			FilterOperator::Gte,
1945			FilterOperator::Lt,
1946			FilterOperator::Lte,
1947		];
1948
1949		for op in operators {
1950			let filter = Filter::new(
1951				"child_id".to_string(),
1952				op.clone(),
1953				FilterValue::OuterRef(OuterRef::new("parent.id")),
1954			);
1955			let result = build_single_filter_expr(&filter);
1956			assert!(
1957				result.is_some(),
1958				"OuterRef with {:?} should produce Some",
1959				op
1960			);
1961		}
1962	}
1963
1964	#[test]
1965	fn test_build_single_filter_expr_expression() {
1966		use reinhardt_db::orm::annotation::{AnnotationValue, Value};
1967
1968		// Test: price > (cost * 2)
1969		let expr = Expression::Multiply(
1970			Box::new(AnnotationValue::Field(F::new("cost"))),
1971			Box::new(AnnotationValue::Value(Value::Int(2))),
1972		);
1973		let filter = Filter::new(
1974			"price".to_string(),
1975			FilterOperator::Gt,
1976			FilterValue::Expression(expr),
1977		);
1978		let result = build_single_filter_expr(&filter);
1979		assert!(result.is_some());
1980	}
1981
1982	#[test]
1983	fn test_build_single_filter_expr_expression_all_operators() {
1984		use reinhardt_db::orm::annotation::{AnnotationValue, Value as OrmValue};
1985
1986		let operators = [
1987			FilterOperator::Eq,
1988			FilterOperator::Ne,
1989			FilterOperator::Gt,
1990			FilterOperator::Gte,
1991			FilterOperator::Lt,
1992			FilterOperator::Lte,
1993		];
1994
1995		for op in operators {
1996			let expr = Expression::Add(
1997				Box::new(AnnotationValue::Field(F::new("base"))),
1998				Box::new(AnnotationValue::Value(OrmValue::Int(10))),
1999			);
2000			let filter = Filter::new(
2001				"total".to_string(),
2002				op.clone(),
2003				FilterValue::Expression(expr),
2004			);
2005			let result = build_single_filter_expr(&filter);
2006			assert!(
2007				result.is_some(),
2008				"Expression with {:?} should produce Some",
2009				op
2010			);
2011		}
2012	}
2013
2014	#[test]
2015	fn test_build_single_filter_expr_uses_transformed_filter_lhs() {
2016		// Arrange
2017		let filter = reinhardt_db::orm::expressions::FieldRef::<(), i64>::new("created_at")
2018			.year()
2019			.range(2024, 2026);
2020
2021		// Act
2022		let result = build_single_filter_expr(&filter);
2023
2024		// Assert
2025		assert!(result.is_some());
2026		let query = Query::select()
2027			.from(Alias::new("users"))
2028			.column(ColumnRef::Asterisk)
2029			.cond_where(Condition::all().add(result.unwrap()))
2030			.to_string(PostgresQueryBuilder);
2031		assert_eq!(
2032			query,
2033			r#"SELECT * FROM "users" WHERE EXTRACT(YEAR FROM "created_at") BETWEEN 2024 AND 2026"#
2034		);
2035	}
2036
2037	#[rstest]
2038	fn test_filter_value_to_sea_value_preserves_timestamp_binding() {
2039		// Arrange
2040		let timestamp = chrono::DateTime::parse_from_rfc3339("2026-07-26T00:00:00Z")
2041			.expect("timestamp should be valid")
2042			.with_timezone(&chrono::Utc);
2043		let value = FilterValue::Timestamp(timestamp);
2044
2045		// Act
2046		let sea_value = filter_value_to_sea_value(&value);
2047
2048		// Assert
2049		assert_eq!(
2050			sea_value,
2051			Value::ChronoDateTimeUtc(Some(Box::new(timestamp)))
2052		);
2053	}
2054
2055	#[rstest]
2056	fn test_filter_value_to_sea_value_preserves_uuid_binding() {
2057		// Arrange
2058		let uuid =
2059			uuid::Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").expect("UUID is valid");
2060		let value = FilterValue::Uuid(uuid);
2061
2062		// Act
2063		let sea_value = filter_value_to_sea_value(&value);
2064
2065		// Assert
2066		assert_eq!(sea_value, Value::Uuid(Some(Box::new(uuid))));
2067	}
2068
2069	#[test]
2070	fn test_filter_value_to_sea_value_field_ref_fallback() {
2071		let value = FilterValue::FieldRef(F::new("test_field"));
2072		let sea_value = filter_value_to_sea_value(&value);
2073
2074		// Should return string representation, not panic
2075		match sea_value {
2076			Value::String(Some(s)) => assert_eq!(s.as_str(), "test_field"),
2077			_ => panic!("Expected String value"),
2078		}
2079	}
2080
2081	#[test]
2082	fn test_filter_value_to_sea_value_outer_ref_fallback() {
2083		let value = FilterValue::OuterRef(OuterRef::new("outer.field"));
2084		let sea_value = filter_value_to_sea_value(&value);
2085
2086		// Should return string representation, not panic
2087		match sea_value {
2088			Value::String(Some(s)) => assert_eq!(s.as_str(), "outer.field"),
2089			_ => panic!("Expected String value"),
2090		}
2091	}
2092
2093	#[test]
2094	fn test_filter_value_to_sea_value_expression_fallback() {
2095		use reinhardt_db::orm::annotation::{AnnotationValue, Value as OrmValue};
2096
2097		let expr = Expression::Add(
2098			Box::new(AnnotationValue::Field(F::new("a"))),
2099			Box::new(AnnotationValue::Value(OrmValue::Int(1))),
2100		);
2101		let value = FilterValue::Expression(expr);
2102		let sea_value = filter_value_to_sea_value(&value);
2103
2104		// Should return SQL string representation, not panic
2105		match sea_value {
2106			Value::String(Some(s)) => {
2107				assert!(s.contains("a"), "SQL should contain field name 'a'");
2108				assert!(s.contains("1"), "SQL should contain value '1'");
2109			}
2110			_ => panic!("Expected String value"),
2111		}
2112	}
2113
2114	#[test]
2115	fn test_filter_value_to_sea_value_preserves_timestamp_and_uuid_types() {
2116		let timestamp = chrono::DateTime::parse_from_rfc3339("2026-07-27T00:00:00Z")
2117			.expect("timestamp fixture should parse")
2118			.with_timezone(&chrono::Utc);
2119		let uuid = uuid::Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000")
2120			.expect("UUID fixture should parse");
2121
2122		assert!(matches!(
2123			filter_value_to_sea_value(&FilterValue::Timestamp(timestamp)),
2124			Value::ChronoDateTimeUtc(Some(_))
2125		));
2126		assert!(matches!(
2127			filter_value_to_sea_value(&FilterValue::Uuid(uuid)),
2128			Value::Uuid(Some(_))
2129		));
2130	}
2131
2132	#[test]
2133	fn test_filter_value_to_sea_value_preserves_date_time_and_naive_datetime() {
2134		let date = chrono::NaiveDate::from_ymd_opt(2026, 8, 20).expect("valid date");
2135		let time = chrono::NaiveTime::from_hms_opt(23, 51, 53).expect("valid time");
2136		let naive_datetime = chrono::NaiveDateTime::new(date, time);
2137
2138		assert_eq!(
2139			filter_value_to_sea_value(&FilterValue::Date(date)),
2140			Value::ChronoDate(Some(Box::new(date)))
2141		);
2142		assert_eq!(
2143			filter_value_to_sea_value(&FilterValue::Time(time)),
2144			Value::ChronoTime(Some(Box::new(time)))
2145		);
2146		assert_eq!(
2147			filter_value_to_sea_value(&FilterValue::NaiveDateTime(naive_datetime)),
2148			Value::ChronoDateTime(Some(Box::new(naive_datetime)))
2149		);
2150	}
2151
2152	// ==================== insert values mismatch tests (#1551) ====================
2153
2154	#[rstest]
2155	fn test_insert_values_mismatch_returns_error_not_panic() {
2156		// Arrange
2157		// Simulate the scenario where columns and values count mismatch
2158		// by calling SeaQuery's values() with wrong number of values
2159		let mut query = Query::insert()
2160			.into_table(Alias::new("test_table"))
2161			.to_owned();
2162
2163		let columns = vec![Alias::new("col1"), Alias::new("col2"), Alias::new("col3")];
2164		let values = vec![Value::String(Some(Box::new("val1".to_string())))]; // Only 1 value for 3 columns
2165
2166		// Act
2167		let result = query.columns(columns).values(values);
2168
2169		// Assert - should return Err, not panic
2170		assert!(result.is_err());
2171	}
2172
2173	#[rstest]
2174	fn test_insert_values_matching_count_succeeds() {
2175		// Arrange
2176		let mut query = Query::insert()
2177			.into_table(Alias::new("test_table"))
2178			.to_owned();
2179
2180		let columns = vec![Alias::new("col1"), Alias::new("col2")];
2181		let values = vec![
2182			Value::String(Some(Box::new("val1".to_string()))),
2183			Value::String(Some(Box::new("val2".to_string()))),
2184		];
2185
2186		// Act
2187		let result = query.columns(columns).values(values);
2188
2189		// Assert
2190		assert!(result.is_ok());
2191	}
2192
2193	// ==================== SQL injection prevention tests ====================
2194
2195	#[test]
2196	fn test_outer_ref_filter_uses_safe_column_api() {
2197		// Arrange: OuterRef with a field name that could be an injection attempt
2198		let filter = Filter::new(
2199			"author_id".to_string(),
2200			FilterOperator::Eq,
2201			FilterValue::OuterRef(OuterRef::new("users.id")),
2202		);
2203
2204		// Act
2205		let result = build_single_filter_expr(&filter);
2206
2207		// Assert: should produce a valid expression using quoted identifiers
2208		assert!(result.is_some());
2209		let expr = result.unwrap();
2210		let query = Query::select()
2211			.from(Alias::new("books"))
2212			.column(ColumnRef::Asterisk)
2213			.cond_where(Condition::all().add(expr))
2214			.to_string(PostgresQueryBuilder);
2215		// The field names should be quoted by SeaQuery's Alias, not raw interpolation
2216		assert!(
2217			query.contains("\"author_id\""),
2218			"Column should be properly quoted: {}",
2219			query
2220		);
2221	}
2222
2223	#[test]
2224	fn test_outer_ref_injection_attempt_is_safely_quoted() {
2225		// Arrange: attacker tries SQL injection through OuterRef field name
2226		let filter = Filter::new(
2227			"id".to_string(),
2228			FilterOperator::Eq,
2229			FilterValue::OuterRef(OuterRef::new("id; DROP TABLE users; --")),
2230		);
2231
2232		// Act
2233		let result = build_single_filter_expr(&filter);
2234
2235		// Assert: the injection string should be treated as a quoted identifier
2236		assert!(result.is_some());
2237		let expr = result.unwrap();
2238		let query = Query::select()
2239			.from(Alias::new("items"))
2240			.column(ColumnRef::Asterisk)
2241			.cond_where(Condition::all().add(expr))
2242			.to_string(PostgresQueryBuilder);
2243		// SeaQuery's Alias wraps the name in double quotes, treating the entire
2244		// injection payload as a single identifier name (not executable SQL).
2245		// The right side of the equality uses Expr::col(Alias::new(...)) which
2246		// produces a quoted identifier instead of raw SQL interpolation.
2247		assert!(
2248			query.contains("\"id; DROP TABLE users; --\""),
2249			"Injection payload should be enclosed in double quotes as identifier: {}",
2250			query
2251		);
2252		// Verify the query is a valid single-statement SELECT (no semicolons
2253		// appear outside of the quoted identifier)
2254		let unquoted_parts: Vec<&str> = query.split('"').enumerate()
2255			.filter(|(i, _)| i % 2 == 0) // Even indices are outside quotes
2256			.map(|(_, s)| s)
2257			.collect();
2258		let unquoted_sql = unquoted_parts.join("");
2259		assert!(
2260			!unquoted_sql.contains(';'),
2261			"No semicolons should appear outside quoted identifiers: {}",
2262			query
2263		);
2264	}
2265
2266	#[test]
2267	fn test_expression_filter_uses_safe_api() {
2268		use reinhardt_db::orm::annotation::AnnotationValue;
2269
2270		// Arrange: arithmetic expression (price * quantity)
2271		let expr = Expression::Multiply(
2272			Box::new(AnnotationValue::Field(F::new("unit_price"))),
2273			Box::new(AnnotationValue::Field(F::new("quantity"))),
2274		);
2275		let filter = Filter::new(
2276			"total".to_string(),
2277			FilterOperator::Eq,
2278			FilterValue::Expression(expr),
2279		);
2280
2281		// Act
2282		let result = build_single_filter_expr(&filter);
2283
2284		// Assert
2285		assert!(result.is_some());
2286		let sea_expr = result.unwrap();
2287		let query = Query::select()
2288			.from(Alias::new("orders"))
2289			.column(ColumnRef::Asterisk)
2290			.cond_where(Condition::all().add(sea_expr))
2291			.to_string(PostgresQueryBuilder);
2292		assert!(
2293			query.contains("\"total\""),
2294			"Left side should be quoted: {}",
2295			query
2296		);
2297	}
2298
2299	#[test]
2300	fn test_expression_filter_with_literal_value() {
2301		use reinhardt_db::orm::annotation::{AnnotationValue, Value as OrmValue};
2302
2303		// Arrange: field + literal value
2304		let expr = Expression::Add(
2305			Box::new(AnnotationValue::Field(F::new("price"))),
2306			Box::new(AnnotationValue::Value(OrmValue::Int(100))),
2307		);
2308		let filter = Filter::new(
2309			"adjusted_price".to_string(),
2310			FilterOperator::Gt,
2311			FilterValue::Expression(expr),
2312		);
2313
2314		// Act
2315		let result = build_single_filter_expr(&filter);
2316
2317		// Assert
2318		assert!(result.is_some());
2319	}
2320
2321	#[test]
2322	fn test_outer_ref_all_operators_use_safe_api() {
2323		// Arrange & Act & Assert: verify all comparison operators work with OuterRef
2324		let operators = vec![
2325			FilterOperator::Eq,
2326			FilterOperator::Ne,
2327			FilterOperator::Gt,
2328			FilterOperator::Gte,
2329			FilterOperator::Lt,
2330			FilterOperator::Lte,
2331		];
2332
2333		for op in operators {
2334			let filter = Filter::new(
2335				"field_a".to_string(),
2336				op.clone(),
2337				FilterValue::OuterRef(OuterRef::new("field_b")),
2338			);
2339			let result = build_single_filter_expr(&filter);
2340			assert!(
2341				result.is_some(),
2342				"OuterRef with {:?} should produce Some",
2343				op
2344			);
2345		}
2346	}
2347
2348	// ==================== Case/Coalesce safe expression tests ====================
2349
2350	#[test]
2351	fn test_coalesce_expression_uses_safe_parameterized_api() {
2352		use reinhardt_db::orm::annotation::{AnnotationValue, Value as OrmValue};
2353
2354		// Arrange: COALESCE(field_a, 0)
2355		let expr = Expression::Coalesce(vec![
2356			AnnotationValue::Field(F::new("field_a")),
2357			AnnotationValue::Value(OrmValue::Int(0)),
2358		]);
2359		let filter = Filter::new(
2360			"result".to_string(),
2361			FilterOperator::Gt,
2362			FilterValue::Expression(expr),
2363		);
2364
2365		// Act
2366		let result = build_single_filter_expr(&filter);
2367
2368		// Assert
2369		assert!(result.is_some());
2370		let sea_expr = result.unwrap();
2371		let query = Query::select()
2372			.from(Alias::new("items"))
2373			.column(ColumnRef::Asterisk)
2374			.cond_where(Condition::all().add(sea_expr))
2375			.to_string(PostgresQueryBuilder);
2376		assert!(
2377			query.contains("COALESCE"),
2378			"Should contain COALESCE function: {}",
2379			query
2380		);
2381		assert!(
2382			query.contains("\"result\""),
2383			"Left side should be quoted: {}",
2384			query
2385		);
2386	}
2387
2388	#[test]
2389	fn test_case_expression_uses_safe_api() {
2390		use reinhardt_db::orm::annotation::{
2391			AnnotationValue, Value as OrmValue, When as AnnotWhen,
2392		};
2393		use reinhardt_db::orm::expressions::Q;
2394
2395		// Arrange: CASE WHEN status = 'active' THEN 1 ELSE 0 END
2396		let expr = Expression::Case {
2397			whens: vec![AnnotWhen::new(
2398				Q::new("status", "=", "'active'"),
2399				AnnotationValue::Value(OrmValue::Int(1)),
2400			)],
2401			default: Some(Box::new(AnnotationValue::Value(OrmValue::Int(0)))),
2402		};
2403		let filter = Filter::new(
2404			"priority".to_string(),
2405			FilterOperator::Eq,
2406			FilterValue::Expression(expr),
2407		);
2408
2409		// Act
2410		let result = build_single_filter_expr(&filter);
2411
2412		// Assert
2413		assert!(result.is_some());
2414		let sea_expr = result.unwrap();
2415		let query = Query::select()
2416			.from(Alias::new("tasks"))
2417			.column(ColumnRef::Asterisk)
2418			.cond_where(Condition::all().add(sea_expr))
2419			.to_string(PostgresQueryBuilder);
2420		assert!(
2421			query.contains("CASE"),
2422			"Should contain CASE keyword: {}",
2423			query
2424		);
2425		assert!(
2426			query.contains("WHEN"),
2427			"Should contain WHEN keyword: {}",
2428			query
2429		);
2430		assert!(
2431			query.contains("ELSE"),
2432			"Should contain ELSE keyword: {}",
2433			query
2434		);
2435	}
2436
2437	#[test]
2438	fn test_empty_coalesce_returns_null() {
2439		// Arrange: COALESCE() with no values
2440		let expr = Expression::Coalesce(vec![]);
2441
2442		// Act
2443		let result = annotation_expr_to_safe_expr(&expr);
2444
2445		// Assert: should produce NULL expression without panicking
2446		let query = Query::select()
2447			.from(Alias::new("test"))
2448			.column(ColumnRef::Asterisk)
2449			.cond_where(Condition::all().add(result))
2450			.to_string(PostgresQueryBuilder);
2451		assert!(
2452			query.contains("NULL"),
2453			"Empty COALESCE should produce NULL: {}",
2454			query
2455		);
2456	}
2457
2458	// ==================== Aggregate safe expression tests ====================
2459
2460	#[test]
2461	fn test_aggregate_count_uses_safe_api() {
2462		use reinhardt_db::orm::aggregation::{Aggregate, AggregateFunc};
2463
2464		// Arrange: COUNT(*)
2465		let agg = Aggregate {
2466			func: AggregateFunc::Count,
2467			field: None,
2468			alias: None,
2469			distinct: false,
2470		};
2471
2472		// Act
2473		let result = aggregate_to_safe_expr(&agg);
2474
2475		// Assert
2476		let query = Query::select()
2477			.from(Alias::new("items"))
2478			.expr(result)
2479			.to_string(PostgresQueryBuilder);
2480		assert!(
2481			query.contains("COUNT(*)"),
2482			"Should contain COUNT(*): {}",
2483			query
2484		);
2485	}
2486
2487	#[test]
2488	fn test_aggregate_sum_field_uses_quoted_identifier() {
2489		use reinhardt_db::orm::aggregation::{Aggregate, AggregateFunc};
2490
2491		// Arrange: SUM(price)
2492		let agg = Aggregate {
2493			func: AggregateFunc::Sum,
2494			field: Some("price".to_string()),
2495			alias: None,
2496			distinct: false,
2497		};
2498
2499		// Act
2500		let result = aggregate_to_safe_expr(&agg);
2501
2502		// Assert
2503		let query = Query::select()
2504			.from(Alias::new("orders"))
2505			.expr(result)
2506			.to_string(PostgresQueryBuilder);
2507		assert!(
2508			query.contains("SUM("),
2509			"Should contain SUM function: {}",
2510			query
2511		);
2512		assert!(
2513			query.contains("\"price\""),
2514			"Field name should be quoted: {}",
2515			query
2516		);
2517	}
2518
2519	#[test]
2520	fn test_aggregate_count_distinct_uses_distinct_keyword() {
2521		use reinhardt_db::orm::aggregation::{Aggregate, AggregateFunc};
2522
2523		// Arrange: COUNT(DISTINCT category)
2524		let agg = Aggregate {
2525			func: AggregateFunc::CountDistinct,
2526			field: Some("category".to_string()),
2527			alias: None,
2528			distinct: false, // AggregateFunc::CountDistinct implies DISTINCT
2529		};
2530
2531		// Act
2532		let result = aggregate_to_safe_expr(&agg);
2533
2534		// Assert
2535		let query = Query::select()
2536			.from(Alias::new("products"))
2537			.expr(result)
2538			.to_string(PostgresQueryBuilder);
2539		assert!(
2540			query.contains("COUNT(DISTINCT"),
2541			"Should contain COUNT(DISTINCT: {}",
2542			query
2543		);
2544		assert!(
2545			query.contains("\"category\""),
2546			"Field name should be quoted: {}",
2547			query
2548		);
2549	}
2550
2551	#[test]
2552	fn test_aggregate_injection_attempt_is_quoted() {
2553		use reinhardt_db::orm::aggregation::{Aggregate, AggregateFunc};
2554
2555		// Arrange: attacker tries injection via aggregate field name
2556		let agg = Aggregate {
2557			func: AggregateFunc::Sum,
2558			field: Some("price); DROP TABLE users; --".to_string()),
2559			alias: None,
2560			distinct: false,
2561		};
2562
2563		// Act
2564		let result = aggregate_to_safe_expr(&agg);
2565
2566		// Assert: injection payload should be treated as a quoted identifier
2567		let query = Query::select()
2568			.from(Alias::new("orders"))
2569			.expr(result)
2570			.to_string(PostgresQueryBuilder);
2571		assert!(
2572			query.contains("\"price); DROP TABLE users; --\""),
2573			"Injection payload should be enclosed in double quotes: {}",
2574			query
2575		);
2576	}
2577
2578	// ==================== empty And/Or all-unsupported filter tests (#2943) ====================
2579
2580	#[rstest]
2581	fn test_build_composite_and_all_unsupported_returns_none() {
2582		// Arrange
2583		// Contains + Boolean is an unsupported combo that falls through to None
2584		let filter1 = Filter::new(
2585			"field1".to_string(),
2586			FilterOperator::Contains,
2587			FilterValue::Boolean(true),
2588		);
2589		let filter2 = Filter::new(
2590			"field2".to_string(),
2591			FilterOperator::StartsWith,
2592			FilterValue::Integer(5),
2593		);
2594		let condition = FilterCondition::And(vec![
2595			FilterCondition::Single(filter1),
2596			FilterCondition::Single(filter2),
2597		]);
2598
2599		// Act
2600		let result = build_composite_filter_condition(&condition);
2601
2602		// Assert
2603		assert!(result.is_ok());
2604		assert!(
2605			result.unwrap().is_none(),
2606			"And with all unsupported filters should return None"
2607		);
2608	}
2609
2610	#[rstest]
2611	fn test_build_composite_or_all_unsupported_returns_none() {
2612		// Arrange
2613		let filter1 = Filter::new(
2614			"field1".to_string(),
2615			FilterOperator::Contains,
2616			FilterValue::Boolean(true),
2617		);
2618		let filter2 = Filter::new(
2619			"field2".to_string(),
2620			FilterOperator::StartsWith,
2621			FilterValue::Integer(5),
2622		);
2623		let condition = FilterCondition::Or(vec![
2624			FilterCondition::Single(filter1),
2625			FilterCondition::Single(filter2),
2626		]);
2627
2628		// Act
2629		let result = build_composite_filter_condition(&condition);
2630
2631		// Assert
2632		assert!(result.is_ok());
2633		assert!(
2634			result.unwrap().is_none(),
2635			"Or with all unsupported filters should return None"
2636		);
2637	}
2638
2639	#[rstest]
2640	fn test_build_composite_and_mixed_valid_and_unsupported() {
2641		// Arrange
2642		let valid_filter = Filter::new(
2643			"name".to_string(),
2644			FilterOperator::Eq,
2645			FilterValue::String("Alice".to_string()),
2646		);
2647		let unsupported_filter = Filter::new(
2648			"field2".to_string(),
2649			FilterOperator::Contains,
2650			FilterValue::Boolean(true),
2651		);
2652		let condition = FilterCondition::And(vec![
2653			FilterCondition::Single(valid_filter),
2654			FilterCondition::Single(unsupported_filter),
2655		]);
2656
2657		// Act
2658		let result = build_composite_filter_condition(&condition);
2659
2660		// Assert
2661		assert!(result.is_ok());
2662		let cond = result.unwrap();
2663		assert!(
2664			cond.is_some(),
2665			"And with at least one valid filter should return Some"
2666		);
2667		let query = Query::select()
2668			.from(Alias::new("t"))
2669			.column(ColumnRef::Asterisk)
2670			.cond_where(cond.unwrap())
2671			.to_string(PostgresQueryBuilder);
2672		assert!(
2673			query.contains("\"name\""),
2674			"SQL should contain the valid filter field, got: {}",
2675			query
2676		);
2677		assert!(
2678			query.contains("'Alice'"),
2679			"SQL should contain the valid filter value, got: {}",
2680			query
2681		);
2682	}
2683
2684	#[rstest]
2685	fn test_build_composite_or_mixed_valid_and_unsupported() {
2686		// Arrange
2687		let valid_filter = Filter::new(
2688			"email".to_string(),
2689			FilterOperator::Eq,
2690			FilterValue::String("test@example.com".to_string()),
2691		);
2692		let unsupported_filter = Filter::new(
2693			"field2".to_string(),
2694			FilterOperator::StartsWith,
2695			FilterValue::Integer(5),
2696		);
2697		let condition = FilterCondition::Or(vec![
2698			FilterCondition::Single(valid_filter),
2699			FilterCondition::Single(unsupported_filter),
2700		]);
2701
2702		// Act
2703		let result = build_composite_filter_condition(&condition);
2704
2705		// Assert
2706		assert!(result.is_ok());
2707		let cond = result.unwrap();
2708		assert!(
2709			cond.is_some(),
2710			"Or with at least one valid filter should return Some"
2711		);
2712		let query = Query::select()
2713			.from(Alias::new("t"))
2714			.column(ColumnRef::Asterisk)
2715			.cond_where(cond.unwrap())
2716			.to_string(PostgresQueryBuilder);
2717		assert!(
2718			query.contains("\"email\""),
2719			"SQL should contain the valid filter field, got: {}",
2720			query
2721		);
2722		assert!(
2723			query.contains("'test@example.com'"),
2724			"SQL should contain the valid filter value, got: {}",
2725			query
2726		);
2727	}
2728
2729	#[rstest]
2730	fn test_build_filter_condition_all_unsupported_returns_none() {
2731		// Arrange
2732		let filters = vec![
2733			Filter::new(
2734				"field1".to_string(),
2735				FilterOperator::Contains,
2736				FilterValue::Boolean(true),
2737			),
2738			Filter::new(
2739				"field2".to_string(),
2740				FilterOperator::StartsWith,
2741				FilterValue::Integer(5),
2742			),
2743		];
2744
2745		// Act
2746		let result = build_filter_condition(&filters);
2747
2748		// Assert
2749		assert!(
2750			result.is_none(),
2751			"build_filter_condition with all unsupported filters should return None"
2752		);
2753	}
2754
2755	#[rstest]
2756	fn object_scope_rejects_filters_that_cannot_be_enforced() {
2757		let unsupported = Filter::new(
2758			"tenant_id".to_string(),
2759			FilterOperator::Contains,
2760			FilterValue::Boolean(true),
2761		);
2762		let empty_scope = Filter::new(
2763			"tenant_id".to_string(),
2764			FilterOperator::In,
2765			FilterValue::List(Vec::new()),
2766		);
2767
2768		assert!(build_object_scope_condition(&[unsupported]).is_err());
2769		assert!(build_object_scope_condition(&[empty_scope]).is_err());
2770		assert!(build_object_scope_condition(&[]).unwrap().is_none());
2771	}
2772
2773	// ==================== extract_count_from_row tests (#2945) ====================
2774
2775	#[rstest]
2776	fn test_extract_count_from_row_with_count_key() {
2777		// Arrange
2778		let data = serde_json::json!({"count": 42});
2779
2780		// Act
2781		let result = extract_count_from_row(&data);
2782
2783		// Assert
2784		assert_eq!(result.unwrap(), 42);
2785	}
2786
2787	#[rstest]
2788	fn test_extract_count_from_row_without_count_key() {
2789		// Arrange
2790		let data = serde_json::json!({"total": 10});
2791
2792		// Act
2793		let result = extract_count_from_row(&data);
2794
2795		// Assert
2796		let err = result.unwrap_err();
2797		assert!(
2798			err.to_string().contains("missing 'count' key"),
2799			"Error should mention missing 'count' key, got: {}",
2800			err
2801		);
2802	}
2803
2804	#[rstest]
2805	fn test_extract_count_from_row_empty_object() {
2806		// Arrange
2807		let data = serde_json::json!({});
2808
2809		// Act
2810		let result = extract_count_from_row(&data);
2811
2812		// Assert
2813		let err = result.unwrap_err();
2814		assert!(
2815			err.to_string().contains("missing 'count' key"),
2816			"Error should mention missing 'count' key, got: {}",
2817			err
2818		);
2819	}
2820
2821	#[rstest]
2822	fn test_extract_count_from_row_non_integer() {
2823		// Arrange
2824		let data = serde_json::json!({"count": "abc"});
2825
2826		// Act
2827		let result = extract_count_from_row(&data);
2828
2829		// Assert
2830		let err = result.unwrap_err();
2831		assert!(
2832			err.to_string().contains("non-integer"),
2833			"Error should mention non-integer value, got: {}",
2834			err
2835		);
2836	}
2837
2838	#[rstest]
2839	fn test_extract_count_from_row_null_data() {
2840		// Arrange
2841		let data = serde_json::Value::Null;
2842
2843		// Act
2844		let result = extract_count_from_row(&data);
2845
2846		// Assert
2847		let err = result.unwrap_err();
2848		assert!(
2849			err.to_string().contains("unexpected data format"),
2850			"Error should mention unexpected data format, got: {}",
2851			err
2852		);
2853	}
2854
2855	#[rstest]
2856	fn test_extract_count_from_row_zero() {
2857		// Arrange
2858		let data = serde_json::json!({"count": 0});
2859
2860		// Act
2861		let result = extract_count_from_row(&data);
2862
2863		// Assert
2864		assert_eq!(result.unwrap(), 0);
2865	}
2866
2867	// ==================== AdminDatabase inject tests ====================
2868
2869	#[rstest]
2870	#[tokio::test]
2871	async fn test_admin_database_inject_error_hint_mentions_connection() {
2872		// Arrange
2873		let singleton = Arc::new(reinhardt_di::SingletonScope::new());
2874		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
2875
2876		// Act
2877		let result = AdminDatabase::inject(&ctx).await;
2878
2879		// Assert
2880		assert!(result.is_err());
2881		let err = result.err().unwrap();
2882		assert!(
2883			err.to_string().contains("DatabaseConnection"),
2884			"Error hint should mention DatabaseConnection, got: {}",
2885			err
2886		);
2887	}
2888
2889	#[rstest]
2890	#[tokio::test]
2891	async fn test_admin_database_inject_returns_prebuilt_from_singleton() {
2892		// Arrange - simulate pre-built AdminDatabase via configure_di pattern
2893		let singleton = Arc::new(reinhardt_di::SingletonScope::new());
2894		// We cannot create a real DatabaseConnection without a DB, so test
2895		// the prebuilt path by directly setting AdminDatabase in singleton
2896		// This verifies backward compat: pre-set AdminDatabase is found first
2897
2898		// Create a mock-like AdminDatabase would require DatabaseConnection,
2899		// so we just verify the error path when nothing is registered
2900		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
2901
2902		// Act
2903		let result = AdminDatabase::inject(&ctx).await;
2904
2905		// Assert - should fail with NotRegistered since no DatabaseConnection
2906		assert!(result.is_err());
2907		let err = result.err().unwrap();
2908		match err {
2909			reinhardt_di::DiError::NotRegistered { type_name, hint } => {
2910				assert_eq!(type_name, "AdminDatabase");
2911				assert_eq!(
2912					hint,
2913					"DatabaseConnection must be registered as a singleton. \
2914					 Use InjectionContextBuilder::singleton(db_connection) during setup."
2915				);
2916			}
2917			other => panic!("Expected NotRegistered error, got: {other:?}"),
2918		}
2919	}
2920
2921	#[rstest]
2922	#[tokio::test]
2923	async fn test_admin_database_keyed_provider_reports_missing_connection() {
2924		let singleton = Arc::new(reinhardt_di::SingletonScope::new());
2925		let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
2926
2927		let result =
2928			reinhardt_di::Depends::<AdminDatabaseKey, AdminDatabase>::resolve_from_registry(
2929				&ctx, true,
2930			)
2931			.await;
2932
2933		assert!(result.is_err());
2934		let err = result.err().unwrap();
2935		match err {
2936			reinhardt_di::DiError::NotRegistered { type_name, hint } => {
2937				assert_eq!(type_name, "AdminDatabase");
2938				assert_eq!(
2939					hint,
2940					"DatabaseConnection must be registered as a singleton. \
2941					 Use InjectionContextBuilder::singleton(db_connection) during setup."
2942				);
2943			}
2944			other => panic!("Expected NotRegistered error, got: {other:?}"),
2945		}
2946	}
2947
2948	// ==================== FilterValue::Array In/NotIn tests (#2936) ====================
2949
2950	#[rstest]
2951	fn test_build_single_filter_expr_array_in() {
2952		// Arrange
2953		let filter = Filter::new(
2954			"status".to_string(),
2955			FilterOperator::In,
2956			FilterValue::Array(vec!["a".to_string(), "b".to_string(), "c".to_string()]),
2957		);
2958
2959		// Act
2960		let result = build_single_filter_expr(&filter);
2961
2962		// Assert
2963		assert!(
2964			result.is_some(),
2965			"Array In with non-empty values should return Some"
2966		);
2967		let query = Query::select()
2968			.from(Alias::new("table"))
2969			.column(ColumnRef::Asterisk)
2970			.cond_where(Condition::all().add(result.unwrap()))
2971			.to_string(PostgresQueryBuilder);
2972		assert!(query.contains("IN"), "SQL should contain IN operator");
2973		assert!(query.contains("'a'"), "SQL should contain value 'a'");
2974		assert!(query.contains("'b'"), "SQL should contain value 'b'");
2975		assert!(query.contains("'c'"), "SQL should contain value 'c'");
2976	}
2977
2978	#[rstest]
2979	fn test_build_single_filter_expr_array_not_in() {
2980		// Arrange
2981		let filter = Filter::new(
2982			"status".to_string(),
2983			FilterOperator::NotIn,
2984			FilterValue::Array(vec!["x".to_string(), "y".to_string()]),
2985		);
2986
2987		// Act
2988		let result = build_single_filter_expr(&filter);
2989
2990		// Assert
2991		assert!(
2992			result.is_some(),
2993			"Array NotIn with non-empty values should return Some"
2994		);
2995		let query = Query::select()
2996			.from(Alias::new("table"))
2997			.column(ColumnRef::Asterisk)
2998			.cond_where(Condition::all().add(result.unwrap()))
2999			.to_string(PostgresQueryBuilder);
3000		assert!(
3001			query.contains("NOT IN"),
3002			"SQL should contain NOT IN operator"
3003		);
3004		assert!(query.contains("'x'"), "SQL should contain value 'x'");
3005		assert!(query.contains("'y'"), "SQL should contain value 'y'");
3006	}
3007
3008	#[rstest]
3009	fn test_build_single_filter_expr_array_in_empty() {
3010		// Arrange
3011		let filter = Filter::new(
3012			"status".to_string(),
3013			FilterOperator::In,
3014			FilterValue::Array(vec![]),
3015		);
3016
3017		// Act
3018		let result = build_single_filter_expr(&filter);
3019
3020		// Assert
3021		assert!(
3022			result.is_none(),
3023			"Array In with empty values should return None"
3024		);
3025	}
3026
3027	#[rstest]
3028	fn test_build_single_filter_expr_array_in_single_element() {
3029		// Arrange
3030		let filter = Filter::new(
3031			"category".to_string(),
3032			FilterOperator::In,
3033			FilterValue::Array(vec!["solo".to_string()]),
3034		);
3035
3036		// Act
3037		let result = build_single_filter_expr(&filter);
3038
3039		// Assert
3040		assert!(
3041			result.is_some(),
3042			"Array In with single element should return Some"
3043		);
3044		let query = Query::select()
3045			.from(Alias::new("table"))
3046			.column(ColumnRef::Asterisk)
3047			.cond_where(Condition::all().add(result.unwrap()))
3048			.to_string(PostgresQueryBuilder);
3049		assert!(query.contains("IN"), "SQL should contain IN operator");
3050		assert!(query.contains("'solo'"), "SQL should contain value 'solo'");
3051	}
3052
3053	#[rstest]
3054	fn test_build_single_filter_expr_array_in_special_chars() {
3055		// Arrange
3056		let filter = Filter::new(
3057			"name".to_string(),
3058			FilterOperator::In,
3059			FilterValue::Array(vec!["O'Brien".to_string(), "a;DROP TABLE".to_string()]),
3060		);
3061
3062		// Act
3063		let result = build_single_filter_expr(&filter);
3064
3065		// Assert
3066		assert!(
3067			result.is_some(),
3068			"Array In with special chars should return Some"
3069		);
3070		let query = Query::select()
3071			.from(Alias::new("table"))
3072			.column(ColumnRef::Asterisk)
3073			.cond_where(Condition::all().add(result.unwrap()))
3074			.to_string(PostgresQueryBuilder);
3075		assert!(query.contains("IN"), "SQL should contain IN operator");
3076		// SeaQuery's to_string with PostgresQueryBuilder escapes single quotes by doubling them
3077		assert!(
3078			query.contains("O''Brien"),
3079			"Single quote in value should be escaped, got: {}",
3080			query
3081		);
3082		// SQL injection attempt should be safely enclosed as a quoted string literal
3083		assert!(
3084			query.contains("'a;DROP TABLE'"),
3085			"SQL injection attempt should be safely quoted as a string literal, got: {}",
3086			query
3087		);
3088	}
3089
3090	// ==================== Bug #2943: Composite filter WHERE TRUE tests ====================
3091
3092	#[rstest]
3093	fn test_and_with_all_unsupported_returns_none() {
3094		// Arrange: Contains with Integer is unsupported (only String is handled)
3095		let unsupported1 = FilterCondition::Single(Filter::new(
3096			"name",
3097			FilterOperator::Contains,
3098			FilterValue::Integer(42),
3099		));
3100		let unsupported2 = FilterCondition::Single(Filter::new(
3101			"email",
3102			FilterOperator::StartsWith,
3103			FilterValue::Integer(99),
3104		));
3105		let condition = FilterCondition::And(vec![unsupported1, unsupported2]);
3106
3107		// Act
3108		let result = build_composite_filter_condition(&condition);
3109
3110		// Assert: And with all unsupported sub-conditions returns None
3111		// (fixed in #2943: previously returned empty Condition::all() generating WHERE TRUE)
3112		assert!(result.is_ok());
3113		let cond = result.unwrap();
3114		assert!(
3115			cond.is_none(),
3116			"And with all unsupported sub-conditions should return None"
3117		);
3118	}
3119
3120	#[rstest]
3121	fn test_or_with_all_unsupported_returns_none() {
3122		// Arrange: Contains/StartsWith with Integer are unsupported
3123		let unsupported1 = FilterCondition::Single(Filter::new(
3124			"name",
3125			FilterOperator::Contains,
3126			FilterValue::Integer(42),
3127		));
3128		let unsupported2 = FilterCondition::Single(Filter::new(
3129			"email",
3130			FilterOperator::StartsWith,
3131			FilterValue::Integer(99),
3132		));
3133		let condition = FilterCondition::Or(vec![unsupported1, unsupported2]);
3134
3135		// Act
3136		let result = build_composite_filter_condition(&condition);
3137
3138		// Assert: Or with all unsupported sub-conditions returns None
3139		// (fixed in #2943: previously returned empty Condition::any() generating WHERE FALSE)
3140		assert!(result.is_ok());
3141		let cond = result.unwrap();
3142		assert!(
3143			cond.is_none(),
3144			"Or with all unsupported sub-conditions should return None"
3145		);
3146	}
3147
3148	#[rstest]
3149	fn test_and_with_mix_supported_unsupported_keeps_supported() {
3150		// Arrange: One supported (Eq + String), one unsupported (Contains + Integer)
3151		let supported = FilterCondition::Single(Filter::new(
3152			"name",
3153			FilterOperator::Eq,
3154			FilterValue::String("Alice".to_string()),
3155		));
3156		let unsupported = FilterCondition::Single(Filter::new(
3157			"email",
3158			FilterOperator::Contains,
3159			FilterValue::Integer(42),
3160		));
3161		let condition = FilterCondition::And(vec![supported, unsupported]);
3162
3163		// Act
3164		let result = build_composite_filter_condition(&condition);
3165
3166		// Assert: Should keep the supported filter condition
3167		assert!(result.is_ok());
3168		let cond = result.unwrap();
3169		assert!(
3170			cond.is_some(),
3171			"And with mix of supported/unsupported should return Some with supported filters"
3172		);
3173		// Verify the supported condition is preserved by building SQL
3174		let query = Query::select()
3175			.from(Alias::new("test"))
3176			.column(ColumnRef::Asterisk)
3177			.cond_where(cond.unwrap())
3178			.to_string(PostgresQueryBuilder);
3179		assert!(
3180			query.contains("\"name\""),
3181			"SQL should contain the supported filter field 'name': {}",
3182			query
3183		);
3184	}
3185
3186	#[rstest]
3187	fn test_or_with_one_supported_one_unsupported() {
3188		// Arrange
3189		let supported = FilterCondition::Single(Filter::new(
3190			"status",
3191			FilterOperator::Eq,
3192			FilterValue::String("active".to_string()),
3193		));
3194		let unsupported = FilterCondition::Single(Filter::new(
3195			"count",
3196			FilterOperator::Contains,
3197			FilterValue::Integer(42),
3198		));
3199		let condition = FilterCondition::Or(vec![supported, unsupported]);
3200
3201		// Act
3202		let result = build_composite_filter_condition(&condition);
3203
3204		// Assert
3205		assert!(result.is_ok());
3206		let cond = result.unwrap();
3207		assert!(
3208			cond.is_some(),
3209			"Or with one supported condition should return Some"
3210		);
3211		let query = Query::select()
3212			.from(Alias::new("test"))
3213			.column(ColumnRef::Asterisk)
3214			.cond_where(cond.unwrap())
3215			.to_string(PostgresQueryBuilder);
3216		assert!(
3217			query.contains("\"status\""),
3218			"SQL should contain the supported filter field 'status': {}",
3219			query
3220		);
3221	}
3222
3223	// ==================== Bug #2945: extract_count_from_row tests ====================
3224
3225	#[rstest]
3226	fn test_extract_count_with_count_key() {
3227		// Arrange
3228		let data = serde_json::json!({"count": 42});
3229
3230		// Act
3231		let result = extract_count_from_row(&data);
3232
3233		// Assert
3234		assert!(result.is_ok());
3235		assert_eq!(result.unwrap(), 42);
3236	}
3237
3238	#[rstest]
3239	fn test_extract_count_without_count_key_returns_error() {
3240		// Arrange: Single non-"count" key
3241		let data = serde_json::json!({"total": 42});
3242
3243		// Act
3244		let result = extract_count_from_row(&data);
3245
3246		// Assert: Missing "count" key now returns error with available keys
3247		// (fixed in #2945: previously fell back to first value from iteration order)
3248		assert!(result.is_err());
3249		let err = result.unwrap_err();
3250		assert!(
3251			err.to_string().contains("missing 'count' key"),
3252			"Error should mention missing 'count' key, got: {}",
3253			err
3254		);
3255	}
3256
3257	#[rstest]
3258	fn test_extract_count_with_multiple_keys_no_count_returns_error() {
3259		// Arrange: Multiple keys, no "count" key
3260		let data = serde_json::json!({"total": 42, "other": 99});
3261
3262		// Act
3263		let result = extract_count_from_row(&data);
3264
3265		// Assert: Missing "count" key returns error listing available keys
3266		// (fixed in #2945: previously used fragile obj.values().next() fallback)
3267		assert!(result.is_err());
3268		let err = result.unwrap_err();
3269		assert!(
3270			err.to_string().contains("available keys"),
3271			"Error should list available keys, got: {}",
3272			err
3273		);
3274	}
3275
3276	#[rstest]
3277	fn test_extract_count_non_integer_returns_error() {
3278		// Arrange
3279		let data = serde_json::json!({"count": "not_a_number"});
3280
3281		// Act
3282		let result = extract_count_from_row(&data);
3283
3284		// Assert
3285		assert!(result.is_err());
3286		let err = result.unwrap_err();
3287		assert!(matches!(err, AdminError::DatabaseError(_)));
3288	}
3289
3290	#[rstest]
3291	fn test_extract_count_null_returns_error() {
3292		// Arrange
3293		let data = serde_json::json!({"count": null});
3294
3295		// Act
3296		let result = extract_count_from_row(&data);
3297
3298		// Assert
3299		assert!(result.is_err());
3300	}
3301
3302	#[rstest]
3303	fn test_extract_count_empty_object_returns_error() {
3304		// Arrange
3305		let data = serde_json::json!({});
3306
3307		// Act
3308		let result = extract_count_from_row(&data);
3309
3310		// Assert
3311		assert!(result.is_err());
3312	}
3313
3314	#[rstest]
3315	fn test_extract_count_non_object_returns_error() {
3316		// Arrange: Array instead of object
3317		let data = serde_json::json!([1, 2, 3]);
3318
3319		// Act
3320		let result = extract_count_from_row(&data);
3321
3322		// Assert
3323		assert!(result.is_err());
3324	}
3325
3326	// ==================== parse_pk_value tests ====================
3327
3328	#[rstest]
3329	fn test_parse_pk_value_integer_falls_back_to_bigint() {
3330		// Arrange: No registry entry for this table, integer string input
3331
3332		// Act
3333		let val = parse_pk_value("nonexistent_table", "id", "42");
3334
3335		// Assert
3336		assert_eq!(val, Value::BigInt(Some(42)));
3337	}
3338
3339	#[rstest]
3340	fn test_parse_pk_value_uuid_string_without_registry_falls_back_to_string() {
3341		// Arrange: No registry entry, UUID string input
3342
3343		// Act
3344		let val = parse_pk_value(
3345			"nonexistent_table",
3346			"id",
3347			"c1a363b1-cc42-4dea-81f0-9dc1cedf0083",
3348		);
3349
3350		// Assert: Without registry metadata, UUID falls back to Value::String
3351		assert!(matches!(val, Value::String(Some(_))));
3352	}
3353
3354	#[rstest]
3355	fn test_parse_pk_value_non_numeric_string_falls_back_to_string() {
3356		// Arrange: No registry entry, non-numeric string input
3357
3358		// Act
3359		let val = parse_pk_value("nonexistent_table", "id", "hello-world");
3360
3361		// Assert
3362		assert!(matches!(val, Value::String(Some(_))));
3363	}
3364
3365	#[rstest]
3366	fn test_parse_pk_value_negative_integer() {
3367		// Arrange: Negative integer string
3368
3369		// Act
3370		let val = parse_pk_value("nonexistent_table", "id", "-1");
3371
3372		// Assert
3373		assert_eq!(val, Value::BigInt(Some(-1)));
3374	}
3375
3376	#[rstest]
3377	fn test_parse_pk_value_zero() {
3378		// Arrange: Zero as string
3379
3380		// Act
3381		let val = parse_pk_value("nonexistent_table", "id", "0");
3382
3383		// Assert
3384		assert_eq!(val, Value::BigInt(Some(0)));
3385	}
3386}