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