djogi 0.1.0-alpha.2

Model-first web framework for Rust — web-framework-agnostic core; Axum integration opt-in via the `axum` feature flag
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! Programmatic filter API — the closure-free path for callers who can't
//! express their filters as a `|f|` closure at compile time.
//!
//! # What
//!
//! [`Lookup<V>`] is the stable user-facing enum enumerating the lookup
//! operators a `{Model}Filter` setter accepts. Variants carrying a value
//! bound `V: IntoFilterValue` so every type the typed [`FieldRef`] path
//! accepts (strings, integers, `HeerId`, `RanjId`, …) composes here too.
//!
//! [`FilterClause`] is the type-erased record a macro-emitted setter
//! appends to its internal `Vec`. Erasing the value here — the typed
//! `Lookup<V>` is projected through [`IntoFilterValue`] into a
//! [`FilterValue`] discriminant — is what lets a single `{Model}Filter`
//! struct carry a mixed set of clauses (a bool clause next to an `i32`
//! clause next to a string clause) without a per-clause generic tuple.
//!
//! [`ModelFilter`] is the trait every macro-emitted `{Model}Filter`
//! implements. Its sole method hands the accumulated `Vec<FilterClause>`
//! to [`QuerySet::filter_struct`], which folds them into a single
//! [`Condition::And`] and AND-s that onto the queryset's existing
//! condition tree.
//!
//! # Why
//!
//! The closure API (`QuerySet::filter(|f| ...)`) is the preferred user
//! surface — the compiler type-checks every lookup against the column's
//! declared type. But three real callers cannot write a closure:
//!
//! 1. **Shell** — Rhai cannot express a Rust closure that closes over the
//!    model's ZST `Fields`. The shell's filter-builder bindings need a
//!    runtime object they can populate one lookup at a time.
//! 2. **Admin UI** — the filter bar sends key/op/value triples over HTTP;
//!    the server reconstructs a filter without ever seeing a user-written
//!    closure.
//! 3. **Dynamic SQL assemblers** — search/export jobs that stitch a query
//!    from a config file or a feature flag.
//!
//! Both paths preserve the same **query result semantics**, but they do
//! not always materialize the same internal predicate tree: closure
//! filters build typed portable predicates directly, while
//! `{Model}Filter` stores erased [`FilterClause`] values and lazily
//! reconstructs portable `Q` leaves only for owner-approved portable
//! cases (`bool`/`String` Eq/Neq/In/NotIn). Everything else
//! conservatively falls back to `Q::Condition`, preserving SQL behavior.
//!
//! # How (user surface)
//!
//! ```ignore
//! use djogi::prelude::*;
//!
//! let filter = PostFilter::new()
//!     .published(Lookup::Eq(true))
//!     .view_count(Lookup::Gte(50i32));
//!
//! let rows = Post::objects().filter_struct(filter).fetch_all(&pool).await?;
//! ```
//!
//! # Where
//!
//! - `Condition` / `Leaf` / `FilterValue` / `LookupOp` — [`crate::query::condition`].
//! - `IntoFilterValue` — [`crate::query::field`] (shared with the closure API).
//! - `{Model}Filter` codegen — `djogi-macros/src/model/filter.rs`.
//! - `QuerySet::filter_struct` — [`crate::query::queryset`].
//!
//! [`FieldRef`]: crate::query::FieldRef
//! [`QuerySet::filter_struct`]: crate::query::QuerySet::filter_struct
//! [`Condition::And`]: crate::query::Condition::And

use crate::model::Model;
use crate::query::condition::{Condition, FilterValue, Leaf, LookupOp};
use crate::query::field::IntoFilterValue;
use crate::query::q::{CompoundOp, Q};

/// User-facing lookup constructor — one variant per operator the
/// programmatic filter API exposes.
///
/// Generic over `V` so newtype columns compose the same way they do
/// through [`FieldRef`]: any `V: IntoFilterValue` is accepted by the
/// value-carrying variants, and the single [`FilterClause::from_lookup`]
/// funnel projects every variant through `IntoFilterValue` into a
/// [`FilterValue`].
///
/// Marked `#[non_exhaustive]` — later phases (array ops, JSONB lookups,
/// trigram search) extend this set, and downstream exhaustive matches
/// would break on every such addition.
///
/// [`FieldRef`]: crate::query::FieldRef
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Lookup<V> {
    /// `column = value`.
    Eq(V),
    /// `column <> value`.
    Neq(V),
    /// `column > value`.
    Gt(V),
    /// `column >= value`.
    Gte(V),
    /// `column < value`.
    Lt(V),
    /// `column <= value`.
    Lte(V),
    /// `column IN (v1, v2, …)`.
    In(Vec<V>),
    /// `column NOT IN (v1, v2, …)`.
    NotIn(Vec<V>),
    /// `column IS NULL`.
    IsNull,
    /// `column IS NOT NULL`.
    IsNotNull,
    /// Case-insensitive substring match — `ILIKE '%value%'`.
    Contains(String),
    /// Case-insensitive prefix match — `ILIKE 'value%'`.
    StartsWith(String),
    /// Case-insensitive suffix match — `ILIKE '%value'`.
    EndsWith(String),
    /// `column BETWEEN a AND b` (inclusive on both ends per SQL spec).
    Between(V, V),
    /// Postgres POSIX regex match — `column ~ value` (case-sensitive).
    ///
    /// Routes to the same operator as [`FieldRef::regex`](crate::query::field::FieldRef::regex);
    /// `value` is a Postgres POSIX regex pattern, evaluated entirely
    /// server-side. Djogi does not link a Rust regex engine — this
    /// variant exists because the match itself is a Postgres feature
    /// (the `~` operator). See [`Lookup::IRegex`] for the
    /// case-insensitive counterpart.
    Regex(String),
    /// Postgres POSIX case-insensitive regex match — `column ~* value`.
    ///
    /// Routes to the same operator as
    /// [`FieldRef::iregex`](crate::query::field::FieldRef::iregex);
    /// `value` is a Postgres POSIX regex pattern, evaluated entirely
    /// server-side via the `~*` operator. Djogi does not link a Rust
    /// regex engine — the match runs on the database. See
    /// [`Lookup::Regex`] for the case-sensitive counterpart.
    ///
    /// **SQL-only.** This variant does not lift into a Sassi portable
    /// predicate (which would require a Rust regex engine); it stays on
    /// the djogi side as a `Q::Condition` leaf.
    IRegex(String),
}

impl<V: IntoFilterValue> Lookup<V> {
    /// Project this lookup into the `(operator, value)` pair its SQL
    /// leaf will carry.
    ///
    /// This is the **single point** where operator-value structural
    /// invariants are established:
    ///
    /// - `Between` always pairs with [`FilterValue::Pair`];
    /// - `In` / `NotIn` always pair with [`FilterValue::List`];
    /// - `IsNull` / `IsNotNull` always pair with [`FilterValue::Null`];
    /// - every other variant pairs with a scalar [`FilterValue`].
    ///
    /// Downstream code ([`FilterClause::from_lookup`] in the clause path,
    /// the closure API through [`crate::query::field`]) funnels every
    /// lookup through this method, so the `unreachable!()` branches in
    /// [`crate::query::sql::emit_leaf`] that guard mismatched op/value
    /// shapes are genuinely unreachable from safe code. If a new
    /// [`Lookup`] variant is added, this method is the only place the
    /// pairing needs to be declared.
    ///
    /// # Operator mapping
    ///
    /// The `Contains` / `StartsWith` / `EndsWith` variants map to the
    /// **case-insensitive** `ILIKE`-family operators (`IContains`
    /// / `IStartsWith` / `IEndsWith`), mirroring the closure API's
    /// `.contains` / `.starts_with` / `.ends_with` default. Case-sensitive
    /// substring matching is not currently exposed on [`Lookup`] —
    /// callers who need it reach for the closure API or the raw
    /// `ctx.raw_execute` / `ctx.raw_scalar` escape hatch.
    ///
    /// `Regex` maps to [`LookupOp::Regex`] — the case-sensitive POSIX
    /// operator (`~`). `IRegex` maps to [`LookupOp::IRegex`] — the
    /// case-insensitive POSIX operator (`~*`). Both are SQL-only and
    /// stay on the djogi side as `Q::Condition` leaves.
    pub(crate) fn into_op_value(self) -> (LookupOp, FilterValue) {
        match self {
            Lookup::Eq(v) => (LookupOp::Eq, v.into_filter_value()),
            Lookup::Neq(v) => (LookupOp::Neq, v.into_filter_value()),
            Lookup::Gt(v) => (LookupOp::Gt, v.into_filter_value()),
            Lookup::Gte(v) => (LookupOp::Gte, v.into_filter_value()),
            Lookup::Lt(v) => (LookupOp::Lt, v.into_filter_value()),
            Lookup::Lte(v) => (LookupOp::Lte, v.into_filter_value()),
            Lookup::In(vs) => (
                LookupOp::In,
                FilterValue::List(vs.into_iter().map(|v| v.into_filter_value()).collect()),
            ),
            Lookup::NotIn(vs) => (
                LookupOp::NotIn,
                FilterValue::List(vs.into_iter().map(|v| v.into_filter_value()).collect()),
            ),
            Lookup::IsNull => (LookupOp::IsNull, FilterValue::Null),
            Lookup::IsNotNull => (LookupOp::IsNotNull, FilterValue::Null),
            Lookup::Contains(s) => (LookupOp::IContains, FilterValue::String(s)),
            Lookup::StartsWith(s) => (LookupOp::IStartsWith, FilterValue::String(s)),
            Lookup::EndsWith(s) => (LookupOp::IEndsWith, FilterValue::String(s)),
            Lookup::Between(a, b) => (
                LookupOp::Between,
                FilterValue::Pair(
                    Box::new(a.into_filter_value()),
                    Box::new(b.into_filter_value()),
                ),
            ),
            Lookup::Regex(s) => (LookupOp::Regex, FilterValue::String(s)),
            Lookup::IRegex(s) => (LookupOp::IRegex, FilterValue::String(s)),
        }
    }
}

/// Erased clause — what a macro-emitted `{Model}Filter` setter pushes
/// into its internal `Vec`.
///
/// The `column` name is a macro-baked `&'static str` literal; the macro
/// never derives it from user input. `op` is the SQL operator;
/// [`from_lookup`] chooses it per `Lookup<V>` variant. `value` is the
/// already-projected [`FilterValue`] — the `V: IntoFilterValue` projection
/// happens inside [`from_lookup`], so this struct is plain data and
/// carries no generic parameter. That lets a single
/// `Vec<FilterClause>` hold a heterogeneous set of clauses (a `bool`
/// clause next to an `i32` clause next to a `String` clause) without
/// per-clause boxing gymnastics.
///
/// # Invariants
///
/// Fields are `pub(crate)` so the only path to a `FilterClause` from
/// outside the crate is [`FilterClause::from_lookup`]. That funnel routes
/// every value through [`Lookup::into_op_value`], which pairs each
/// [`LookupOp`] with the structurally correct [`FilterValue`] shape
/// (`Between`↔`Pair`, `In`/`NotIn`↔`List`, `IsNull`/`IsNotNull`↔`Null`,
/// …). Consequently, the `unreachable!()` branches in the SQL emitter
/// (`sql::emit_leaf`) that guard against mismatched op/value pairings
/// are genuinely unreachable from safe code — downstream crates cannot
/// hand-craft an invalid clause by poking at fields directly.
///
/// [`from_lookup`]: FilterClause::from_lookup
#[derive(Debug, Clone)]
pub struct FilterClause {
    /// SQL column name — macro-baked literal, never user input.
    pub(crate) column: &'static str,
    /// Operator discriminant — chosen per [`Lookup`] variant in
    /// [`FilterClause::from_lookup`].
    pub(crate) op: LookupOp,
    /// Already-projected bind value. `FilterValue::Null` is used for the
    /// `IsNull` / `IsNotNull` variants where no bind is emitted.
    pub(crate) value: FilterValue,
}

/// Consumed [`FilterClause`] parts for macro-generated clause-to-`Q` mapping.
///
/// This is hidden implementation surface: generated `{Model}Filter`
/// `IntoQ` impls need to inspect the erased column/op/value tuple after
/// consuming the clause, but external callers still cannot construct a
/// `FilterClause` except through [`FilterClause::from_lookup`].
#[doc(hidden)]
pub struct FilterClauseParts {
    pub column: &'static str,
    pub op: LookupOp,
    pub value: FilterValue,
}

impl FilterClauseParts {
    /// Fall back to the legacy condition leaf for clauses that cannot be
    /// safely reconstructed as portable `Q` leaves.
    #[doc(hidden)]
    pub fn into_condition(self) -> Condition {
        Condition::Leaf(Leaf::new(self.column, self.op, self.value))
    }
}

impl FilterClause {
    /// Project a `Lookup<V>` into a type-erased `FilterClause`.
    ///
    /// This is the **only** public constructor for `FilterClause` — every
    /// macro-emitted setter funnels through it, and the `FilterClause`
    /// fields are `pub(crate)` so downstream crates cannot sidestep it.
    /// Pairing operator and value happens in a single place
    /// ([`Lookup::into_op_value`]), so the `unreachable!()` branches in
    /// [`crate::query::sql::emit_leaf`] that guard shape mismatches are
    /// genuinely unreachable from safe code.
    ///
    /// The `V: IntoFilterValue` bound is the same one the typed
    /// [`FieldRef`] lookup methods use, so newtype columns and
    /// string-like types compose identically in both surfaces.
    ///
    /// [`FieldRef`]: crate::query::FieldRef
    #[must_use]
    pub fn from_lookup<V: IntoFilterValue>(column: &'static str, lookup: Lookup<V>) -> Self {
        let (op, value) = lookup.into_op_value();
        Self { column, op, value }
    }

    /// Turn this clause into a `Condition::Leaf`. Called by
    /// [`QuerySet::filter_struct`] when folding a filter's `Vec` into
    /// the queryset's condition tree.
    ///
    /// [`QuerySet::filter_struct`]: crate::query::QuerySet::filter_struct
    pub fn into_condition(self) -> Condition {
        Condition::Leaf(Leaf::new(self.column, self.op, self.value))
    }

    /// Consume this clause into inspectable parts for macro-generated
    /// lazy conversion to `Q<T>`.
    #[doc(hidden)]
    pub fn into_parts(self) -> FilterClauseParts {
        FilterClauseParts {
            column: self.column,
            op: self.op,
            value: self.value,
        }
    }
}

/// Implemented by every macro-emitted `{Model}Filter`. Exposes the
/// accumulated clauses so [`QuerySet::filter_struct`] can fold them into
/// a single `Condition::And(...)` AND-ed onto the queryset's existing
/// tree.
///
/// Users never implement this trait by hand — the `#[model]` macro
/// stamps it alongside the filter struct.
///
/// # Object safety
///
/// `ModelFilter` is **not** object-safe: [`into_clauses`] takes
/// `self` by value, which is incompatible with `dyn ModelFilter` trait
/// objects (`self: Box<Self>` would be the by-value equivalent, but
/// that forces every caller through a heap allocation and a
/// `Box::new(...)` at the call site). Current callers never need
/// storage-erased filters, so keeping the by-value shape preserves
/// the zero-alloc path and matches the rest of the builder surface
/// (`QuerySet`'s chain methods are also by-value self).
///
/// If a use case needs to store a heterogeneous list of filters
/// (each column's operator and value computed at runtime rather than
/// at compile time), the trait's shape can be extended — either via
/// a sibling `DynModelFilter` trait with `fn into_clauses(
/// self: Box<Self>) -> Vec<FilterClause>`, or via an owned-clauses
/// field on the filter struct.
///
/// [`into_clauses`]: ModelFilter::into_clauses
/// [`QuerySet::filter_struct`]: crate::query::QuerySet::filter_struct
pub trait ModelFilter {
    /// Hand off the accumulated clauses. Consumes `self` — filters are
    /// single-use, matching the queryset builder's own consume-self shape.
    fn into_clauses(self) -> Vec<FilterClause>;
}

/// Fold a clause vec into a single `Condition`, preserving the empty/one/
/// many cases the queryset layer cares about.
///
/// - `[]` → `Condition::True` (vacuous — filter_struct returns early so
///   callers never actually see this case, but keeping the helper total
///   means unit tests can exercise every branch).
/// - `[c]` → `c` unwrapped — emitting a single-element `And` would render
///   as `(c)` with redundant parentheses.
/// - `[c1, c2, ...]` → `Condition::And(vec![c1, c2, ...])`.
///
/// Not public API — this is an implementation detail of `filter_struct`.
/// The plan lists it as a "helper to fold a Vec<FilterClause> into a
/// Condition::And(...) tree"; keeping it in this module means the
/// queryset layer stays free of condition-tree construction details.
///
/// # Visibility
///
/// `pub` on the symbol but routed through `::djogi::__private::query`
/// in the public path tree, so adopter code reaching for it crosses
/// the framework boundary into the unstable `__private` namespace.
/// Macro-emitted `IntoQ<#model>` impls for `{Model}Filter` (Cluster
/// 8γ Stage 2 — T6.7) need to call this from the adopter crate; the
/// `pub(crate)` shape blocks that, while `pub` + `__private`-only
/// re-export preserves the same "internal" contract every other
/// `__private` helper carries (see `feedback_macro_path_routing.md`).
pub fn clauses_into_condition(clauses: Vec<FilterClause>) -> Condition {
    match clauses.len() {
        0 => Condition::True,
        1 => {
            // `into_iter().next().unwrap()` is safe — we just checked len == 1.
            // Using `next()` rather than indexing avoids a pointless clone.
            clauses
                .into_iter()
                .next()
                .expect("len == 1 branch guarantees one element")
                .into_condition()
        }
        _ => Condition::And(
            clauses
                .into_iter()
                .map(FilterClause::into_condition)
                .collect(),
        ),
    }
}

/// Fold consumed clauses into `Q<M>`, delegating leaf reconstruction to
/// macro-generated model-aware code.
///
/// Empty filters are the portable true identity. Single clauses return the
/// mapped leaf directly. Multi-clause filters preserve setter order under an
/// explicit `AND` compound node rather than routing through the legacy
/// `Condition` path.
#[doc(hidden)]
pub fn clauses_into_q<M, F>(clauses: Vec<FilterClause>, mut map: F) -> Q<M>
where
    M: Model,
    F: FnMut(FilterClause) -> Q<M>,
{
    match clauses.len() {
        0 => Q::always_true(),
        1 => {
            let clause = clauses
                .into_iter()
                .next()
                .expect("len == 1 branch guarantees one element");
            map(clause)
        }
        _ => Q::Compound {
            op: CompoundOp::And,
            parts: clauses.into_iter().map(map).collect(),
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lookup_eq_projects_to_eq_op_and_bool_value() {
        let clause = FilterClause::from_lookup("published", Lookup::Eq(true));
        assert_eq!(clause.column, "published");
        assert_eq!(clause.op, LookupOp::Eq);
        assert!(matches!(clause.value, FilterValue::Bool(true)));
    }

    #[test]
    fn filter_clause_from_lookup_composes() {
        // `FilterClause::from_lookup` is the single public constructor
        // for `FilterClause`. It funnels through `Lookup::into_op_value`,
        // which is the one place operator-value structural pairings
        // (Between↔Pair, In/NotIn↔List, IsNull/IsNotNull↔Null, …) are
        // established. This test pins a representative sample from each
        // shape family so a regression to hand-built clauses (or a
        // reshuffled `into_op_value` match arm) is caught by the unit
        // tier rather than by the SQL emitter's downstream
        // `unreachable!()` branches.
        //
        // The column string is preserved verbatim in every case — the
        // macro bakes a `&'static str` literal per setter and this
        // funnel never rewrites it.

        // Scalar: Eq → FilterValue::I32
        let c = FilterClause::from_lookup("view_count", Lookup::Eq(42i32));
        assert_eq!(c.column, "view_count");
        assert_eq!(c.op, LookupOp::Eq);
        assert!(matches!(c.value, FilterValue::I32(42)));

        // List: In → FilterValue::List
        let c = FilterClause::from_lookup("id", Lookup::In(vec![1i64, 2, 3]));
        assert_eq!(c.op, LookupOp::In);
        assert!(matches!(c.value, FilterValue::List(ref v) if v.len() == 3));

        // List: NotIn → FilterValue::List (empty is still List, not Null)
        let c = FilterClause::from_lookup("id", Lookup::<i64>::NotIn(Vec::new()));
        assert_eq!(c.op, LookupOp::NotIn);
        assert!(matches!(c.value, FilterValue::List(ref v) if v.is_empty()));

        // Pair: Between → FilterValue::Pair(Box, Box)
        let c = FilterClause::from_lookup("age", Lookup::Between(10i32, 20i32));
        assert_eq!(c.op, LookupOp::Between);
        assert!(matches!(c.value, FilterValue::Pair(_, _)));

        // Null: IsNull → FilterValue::Null (no projection of V)
        let c = FilterClause::from_lookup("deleted_at", Lookup::<String>::IsNull);
        assert_eq!(c.op, LookupOp::IsNull);
        assert!(matches!(c.value, FilterValue::Null));

        // Null: IsNotNull → FilterValue::Null (symmetry with IsNull)
        let c = FilterClause::from_lookup("deleted_at", Lookup::<String>::IsNotNull);
        assert_eq!(c.op, LookupOp::IsNotNull);
        assert!(matches!(c.value, FilterValue::Null));

        // String family: Contains → case-insensitive IContains operator
        // (pattern wrapping `%…%` happens inside the SQL emitter, not
        // here — the clause carries the raw user string).
        let c = FilterClause::from_lookup("title", Lookup::<String>::Contains("x".to_string()));
        assert_eq!(c.op, LookupOp::IContains);
        assert!(matches!(c.value, FilterValue::String(ref s) if s == "x"));
    }

    #[test]
    fn lookup_gte_projects_to_gte_op_and_i32_value() {
        let clause = FilterClause::from_lookup("view_count", Lookup::Gte(50i32));
        assert_eq!(clause.op, LookupOp::Gte);
        assert!(matches!(clause.value, FilterValue::I32(50)));
    }

    #[test]
    fn lookup_in_builds_list_filter_value() {
        let clause = FilterClause::from_lookup("id", Lookup::In(vec![1i64, 2, 3]));
        assert_eq!(clause.op, LookupOp::In);
        if let FilterValue::List(items) = clause.value {
            assert_eq!(items.len(), 3);
        } else {
            panic!("expected FilterValue::List");
        }
    }

    #[test]
    fn lookup_between_builds_pair_filter_value() {
        let clause = FilterClause::from_lookup("age", Lookup::Between(10i32, 20i32));
        assert_eq!(clause.op, LookupOp::Between);
        assert!(matches!(clause.value, FilterValue::Pair(_, _)));
    }

    #[test]
    fn lookup_is_null_carries_null_filter_value() {
        // `Lookup::IsNull` is not generic over any runtime value, but `V`
        // still has to be nameable so the user writes `Lookup::<i64>::IsNull`
        // or lets type inference fill it in from the surrounding context.
        let clause = FilterClause::from_lookup("deleted_at", Lookup::<String>::IsNull);
        assert_eq!(clause.op, LookupOp::IsNull);
        assert!(matches!(clause.value, FilterValue::Null));
    }

    #[test]
    fn lookup_contains_maps_to_icontains_op() {
        // Documented mapping: `Contains` is the case-insensitive variant —
        // matches the closure API's `.contains` default.
        let clause =
            FilterClause::from_lookup("title", Lookup::<String>::Contains("hi".to_string()));
        assert_eq!(clause.op, LookupOp::IContains);
        assert!(matches!(clause.value, FilterValue::String(ref s) if s == "hi"));
    }

    #[test]
    fn lookup_regex_maps_to_case_sensitive_regex_op() {
        // Documented mapping: plain `Regex` is case-sensitive (`~`), not `~*`.
        let clause = FilterClause::from_lookup("slug", Lookup::<String>::Regex("^foo".to_string()));
        assert_eq!(clause.op, LookupOp::Regex);
    }

    #[test]
    fn lookup_iregex_projects_to_iregex_op_and_string_value() {
        // `Lookup::IRegex` maps to `LookupOp::IRegex` (Postgres `~*` —
        // case-insensitive POSIX regex). The pattern string is passed
        // verbatim as `FilterValue::String`; no Rust regex engine is
        // linked — the match runs server-side.
        let (op, value) = Lookup::<String>::IRegex("^foo".to_string()).into_op_value();
        assert_eq!(op, LookupOp::IRegex);
        assert!(matches!(value, FilterValue::String(ref s) if s == "^foo"));

        // Also verify via the FilterClause funnel that the op/column round-trip
        // is stable — this is the path the macro-emitted setter uses.
        let clause =
            FilterClause::from_lookup("slug", Lookup::<String>::IRegex("^foo".to_string()));
        assert_eq!(clause.column, "slug");
        assert_eq!(clause.op, LookupOp::IRegex);
        assert!(matches!(clause.value, FilterValue::String(ref s) if s == "^foo"));
    }

    #[test]
    fn into_condition_produces_leaf() {
        let clause = FilterClause::from_lookup("title", Lookup::Eq("x".to_string()));
        let cond = clause.into_condition();
        assert!(matches!(cond, Condition::Leaf(_)));
    }

    #[test]
    fn clauses_into_condition_empty_is_true() {
        let c = clauses_into_condition(Vec::new());
        assert!(matches!(c, Condition::True));
    }

    #[test]
    fn clauses_into_condition_single_unwraps_to_leaf() {
        // Single-clause filters should not be wrapped in a one-element And
        // — the SQL emitter would render that as `(leaf)` with redundant
        // parens.
        let clause = FilterClause::from_lookup("published", Lookup::Eq(true));
        let c = clauses_into_condition(vec![clause]);
        assert!(matches!(c, Condition::Leaf(_)));
    }

    #[test]
    fn clauses_into_condition_many_builds_flat_and() {
        let a = FilterClause::from_lookup("published", Lookup::Eq(true));
        let b = FilterClause::from_lookup("view_count", Lookup::Gte(50i32));
        let c = FilterClause::from_lookup("title", Lookup::Neq("draft".to_string()));
        let cond = clauses_into_condition(vec![a, b, c]);
        if let Condition::And(parts) = cond {
            assert_eq!(parts.len(), 3);
            // Order must be preserved — the queryset layer relies on the
            // user's declaration order matching SQL emission order for
            // predictable `EXPLAIN` output.
            for p in &parts {
                assert!(matches!(p, Condition::Leaf(_)));
            }
        } else {
            panic!("expected Condition::And with 3 elements");
        }
    }

    // Minimal struct implementing `ModelFilter` by hand — mirrors what the
    // macro emits, so `into_clauses` is exercised without depending on the
    // macro expansion path.
    struct FakeFilter {
        clauses: Vec<FilterClause>,
    }
    impl ModelFilter for FakeFilter {
        fn into_clauses(self) -> Vec<FilterClause> {
            self.clauses
        }
    }

    #[test]
    fn model_filter_trait_returns_pushed_clauses() {
        let f = FakeFilter {
            clauses: vec![
                FilterClause::from_lookup("a", Lookup::Eq(1i32)),
                FilterClause::from_lookup("b", Lookup::Eq(2i32)),
            ],
        };
        let v = f.into_clauses();
        assert_eq!(v.len(), 2);
        assert_eq!(v[0].column, "a");
        assert_eq!(v[1].column, "b");
    }
}