djogi 0.1.0-alpha.4

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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
//! Internal expression AST node — the untyped dynamic payload behind the
//! typed [`Expr<T>`](super::Expr) wrapper.
//!
//! # What
//!
//! [`ExprNode`] is an untyped enum tree: leaves are `Field { column }` /
//! `Literal(FilterValue)`, internal nodes are arithmetic, comparison, and
//! (in later phases) subquery / aggregate / CASE forms. The typed
//! [`Expr<T>`](super::Expr) wrapper projects `T`-safety over this dynamic
//! core so user-facing APIs stay typed while the SQL emitter only has one
//! variant tree to walk.
//!
//! # Why separate the typed wrapper from the node?
//!
//! - **One emitter walk.** [`super::sql::emit_expr`] matches this enum
//!   exhaustively. If `ExprNode` were polymorphic in `T`, every emitter
//!   call site would monomorphise per-type; by erasing `T` at the enum
//!   boundary we get one codegen path and one set of tests.
//! - **Arithmetic composition.** `Expr<i32> + Expr<i32>` yields `Expr<i32>`
//!   — the typed wrapper enforces the operator is only available for
//!   numeric `T`, but the node it wraps stores a plain `Add(Box<_>, Box<_>)`
//!   regardless of `T`. Same pattern for comparisons (`Expr<T>.eq(Expr<T>)
//!   -> Expr<bool>` — the wrapper changes `T` from `T` to `bool`, the node
//!   is a `Cmp { op: Eq, .. }`).
//! - **Phase expansion.** Tasks 4 / 5 add `Case`, `Exists`, `Subquery`,
//!   `Aggregate`, and `OuterRef` variants. Keeping the enum untyped means
//!   those additions don't ripple into every type-parameterised site; only
//!   the emitter and a few typed constructors grow.
//!
//! # Where
//!
//! - [`super::Expr`] — typed wrapper, the public surface.
//! - [`super::sql::emit_expr`] — the matching emitter (one arm per variant).
//! - [`crate::query::condition::Condition::Expr`] — the bridge that promotes
//!   an `Expr<bool>` into the filter tree.

use crate::query::condition::FilterValue;
use std::any::TypeId;

// Phase 4 Task 5 landed the `Case` / `Exists` / `Subquery` / `OuterRef`
// variants alongside the `SubqueryNode` payload at the bottom of this
// file. The emitter in `expr::sql` has matching arms in lockstep. The
// `#[non_exhaustive]` marker is crate-private today (same-crate matches
// are already exhaustive); it becomes relevant if/when this enum is
// re-exported.

/// Untyped expression tree. The typed [`super::Expr<T>`] wrapper carries
/// the phantom `T` parameter and projects type safety over the dynamic
/// variants stored here. The SQL emitter walks this enum directly.
///
/// Marked `#[non_exhaustive]` to leave room for Phase 4 Tasks 4 / 5
/// additions (`Case`, `Exists`, `Subquery`, `Aggregate`, `OuterRef`) without
/// forcing a downstream semver bump. `Condition::Expr` carries `Expr<bool>`,
/// not `ExprNode`, so the typed seal stays intact at the public boundary.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub(crate) enum ExprNode {
    /// Bare column reference. The `column` string is a `&'static str`
    /// that was validated at [`crate::query::field::FieldRef::new`]
    /// construction time via [`crate::ident::assert_plain_ident`] — it
    /// is safe to `qb.push(*column)` without quoting. The emitter does
    /// not re-validate; the seal is on the constructor.
    Field { column: &'static str },

    /// Raw SQL fragment — Phase 8β T4.2 carve-out for the
    /// `#[computed(sql = "...")]` field surface.
    ///
    /// The fragment is a `&'static str` baked at macro expansion time
    /// from the user-authored SQL expression. The macro does NOT parse
    /// the fragment (per the lens, plan §7 #6 resolved 2026-05-03 —
    /// shipping a tiny in-house SQL parser introduces a
    /// Rust-vs-Postgres parse-divergence surface that is hard to test
    /// exhaustively); it does run a token-level validation pass
    /// confirming that every `\w+`-shaped token resolves to a declared
    /// struct field on the model and that operators come from an
    /// explicit allowlist. Anything outside the allowlist surfaces as
    /// a span-precise compile error.
    ///
    /// Emitter wraps the fragment in outer parens at every emission
    /// site for operator-precedence stability under further
    /// composition (the same pattern as
    /// [`crate::query::condition::Condition::RawSql`] from T3.4).
    ///
    /// Construction goes through
    /// [`crate::expr::Expr::__raw_sql_fragment`], which is
    /// `#[doc(hidden)]` and not part of the user-facing API. The
    /// `__`-prefix matches
    /// [`crate::query::condition::Condition::__from_raw_sql_fragment`]'s
    /// convention so adopters reading the rustdoc cannot mistake it
    /// for a supported surface.
    RawSql(&'static str),

    /// Scalar literal. Every SQL-bindable type Djogi ships with already
    /// lives in [`FilterValue`]; reusing that enum avoids parallel
    /// variant lists and keeps the emitter's bind path in one place
    /// (it delegates to `push_filter_value`).
    Literal(FilterValue),

    /// `lhs + rhs` — integer or floating-point addition. Typed wrapper
    /// gates this on the sealed [`super::arithmetic::Numeric`] trait.
    Add(Box<ExprNode>, Box<ExprNode>),

    /// `lhs - rhs` — integer or floating-point subtraction. Same sealed
    /// gate as [`ExprNode::Add`].
    Sub(Box<ExprNode>, Box<ExprNode>),

    /// `lhs * rhs` — integer or floating-point multiplication. Same sealed
    /// gate as [`ExprNode::Add`].
    Mul(Box<ExprNode>, Box<ExprNode>),

    /// `lhs / rhs` — integer or floating-point division. Same sealed
    /// gate as [`ExprNode::Add`]. Integer division / division-by-zero
    /// semantics are Postgres's; Djogi does not inject a guard.
    Div(Box<ExprNode>, Box<ExprNode>),

    /// `lhs AND rhs` — boolean logical AND.
    And(Box<ExprNode>, Box<ExprNode>),

    /// `lhs OR rhs` — boolean logical OR.
    Or(Box<ExprNode>, Box<ExprNode>),

    /// `NOT expr` — boolean logical NOT.
    Not(Box<ExprNode>),

    /// `lhs <op> rhs` — comparison producing an `Expr<bool>` at the
    /// typed-wrapper layer. See [`CmpOp`] for the operator set.
    Cmp {
        op: CmpOp,
        lhs: Box<ExprNode>,
        rhs: Box<ExprNode>,
    },

    /// `GROUPING(c1, c2, …, cN)` — variadic grouping-set bitmask.
    /// Postgres assigns bit `0` (LSB) to the rightmost argument, so
    /// bit `j` is `1` iff `columns[N-1-j]` was rolled up in the
    /// current row under `GROUP BY ROLLUP` / `CUBE` / `GROUPING SETS`,
    /// else `0`.
    ///
    /// The single-column form `GROUPING(col)` continues to use
    /// `ExprNode::Aggregate { op: AggOp::Grouping, ... }` — the variadic
    /// IR layout adds a new variant rather than retrofitting an N-arg
    /// slot onto `Aggregate` because GROUPING is the only aggregate
    /// taking 2+ column args and Postgres rejects every aggregate
    /// modifier on it (DISTINCT / ORDER BY / FILTER / OVER all error).
    /// A dedicated variant keeps the bulk-of-aggregate fields
    /// (`distinct`, `filter`, `window`, `order_by`, `within_group_order_by`)
    /// from polluting a metadata function that cannot accept them.
    ///
    /// Each arg is `ExprNode::Field { column }` with `column` validated
    /// at constructor time via `crate::ident::assert_plain_ident`. No
    /// other `ExprNode` shape is accepted at the typed-surface level —
    /// the free function `grouping_of` only takes `&[&'static str]`.
    GroupingVariadic {
        /// Non-empty list of column references being flagged. Empty
        /// arg list is rejected at the constructor (Postgres also
        /// rejects `GROUPING()` with 0 args).
        args: Vec<ExprNode>,
    },

    /// Aggregate function call — `COUNT(*)` / `COUNT(col)` / `SUM(col)`
    /// / `AVG(col)` / `MIN(col)` / `MAX(col)` with an optional
    /// `FILTER (WHERE ...)` post-filter clause and an optional window
    /// (`OVER (...)`) clause. The typed
    /// [`super::aggregate::AggregateExpr<Out>`] wrapper carries the Rust
    /// return type (`i64` for `COUNT`, `f64` for `AVG`, `V` for
    /// `SUM`/`MIN`/`MAX`) so the emitted scalar decodes to the right
    /// Rust type without runtime casting.
    ///
    /// `arg` is the column or sub-expression being aggregated. For
    /// `COUNT(*)` the dedicated [`AggOp::CountStar`] variant is paired
    /// with an arbitrary `arg` (the emitter ignores it on that branch);
    /// routing `*` through a separate op — rather than a magic
    /// [`ExprNode::Field { column: "*" }`] sentinel — keeps the bare
    /// star away from
    /// [`crate::ident::assert_plain_ident`] / [`crate::ident::debug_assert_ident!`]
    /// and from the column-qualification pass that select_related adds.
    ///
    /// `filter` is an optional boolean sub-expression that gates which
    /// rows contribute to the aggregate. Postgres emits this as
    /// `AGG(arg) FILTER (WHERE <cond>)`. `None` emits the bare aggregate.
    ///
    /// `distinct` reserves the `DISTINCT` keyword slot for Phase 6.5 T4's
    /// `.distinct()` builder method. Always `false` until T4 wires it.
    ///
    /// `window` is an optional [`super::window::WindowSpec`] that promotes
    /// this aggregate to a window function via `OVER (...)`. Supplied by
    /// the `.over(|w| ...)` method on
    /// [`super::aggregate::AggregateExpr`] (T3). `None` leaves the
    /// aggregate bare; the terminal-layer helpers in `query::sql` add
    /// `OVER ()` for the plain ungrouped annotate path only after the
    /// plain-annotation type-state has proven the aggregate kind is
    /// windowable. Ordered-set, hypothetical-set, and metadata aggregates
    /// are rejected before that synthesized-window path.
    Aggregate {
        /// Which aggregate function to call.
        op: AggOp,
        /// The column or expression being aggregated. Ignored for
        /// [`AggOp::CountStar`] (the emitter renders `COUNT(*)` regardless
        /// of `arg`); the typed [`super::aggregate::AggregateExpr`] surface
        /// stores a placeholder there.
        arg: Box<ExprNode>,
        /// Second argument for binary (two-arg) aggregates. `None` for
        /// the unary family (COUNT / SUM / AVG / MIN / MAX / ARRAY_AGG /
        /// JSONB_AGG / STRING_AGG / BOOL_AND / BOOL_OR / EVERY / BIT_*
        /// / STDDEV_* / VAR_* / GROUPING). `Some(node)` for the binary
        /// family (COVAR_POP / COVAR_SAMP / CORR / REGR_* / JSON_OBJECT_AGG
        /// / JSONB_OBJECT_AGG), where the `arg` slot carries the first
        /// column (`y` for stats / `key` for json-object) and `arg2`
        /// carries the second column (`x` for stats / `value` for
        /// json-object).
        ///
        /// Cluster E T5 introduced this slot to back `covar_pop` / `corr`
        /// / `regr_*` / `jsonb_object_agg`. The slot is backward-compatible
        /// — every pre-existing unary-aggregate constructor (`unary_agg`,
        /// the `string_agg` shape) sets `arg2: None`, so the unary
        /// emission path remains untouched. The emitter ignores `arg2`
        /// on the unary family and renders the comma-separated second
        /// arg only when the variant is recognised as binary.
        arg2: Option<Box<ExprNode>>,
        /// Optional `FILTER (WHERE ...)` clause. `None` emits the bare
        /// aggregate; `Some(cond)` emits
        /// `AGG(arg) FILTER (WHERE <cond>)`.
        filter: Option<Box<ExprNode>>,
        /// Optional explicit Postgres-side cast applied to the aggregate
        /// result. Emits as `AGG(arg)::<cast_to>` before the optional
        /// `FILTER` clause is pushed.
        ///
        /// Why: Postgres widens integer aggregates — `SUM(BIGINT)` returns
        /// `NUMERIC`, `AVG(BIGINT)` returns `NUMERIC`, `SUM(SMALLINT)`
        /// returns `BIGINT`. The typed [`super::aggregate::AggregateExpr`]
        /// surface promises `Out = V` for `SUM` over `V: Numeric` and
        /// `Out = f64` for `AVG`, so the emitter narrows / casts back to
        /// the Rust type the decoder returns. The cast target is always a
        /// framework-baked `&'static str` from
        /// [`super::aggregate`]'s method bodies — never user input.
        cast_to: Option<&'static str>,
        /// When `true`, the `DISTINCT` keyword is emitted before the aggregate
        /// argument: `AGG(DISTINCT col)`. Set via
        /// [`super::aggregate::AggregateExpr::distinct`] (T4), which is
        /// exposed only on the `ValueAgg` kind impl block — non-value
        /// aggregate families ([`AggOp::Grouping`], [`AggOp::PercentileCont`]
        /// / [`AggOp::PercentileDisc`] / [`AggOp::Mode`],
        /// [`AggOp::HypotheticalRank`] / [`AggOp::HypotheticalDenseRank`] /
        /// [`AggOp::HypotheticalPercentRank`] / [`AggOp::HypotheticalCumeDist`])
        /// reject `.distinct()` at compile time through the type-state
        /// (#89). Fetch-time validation in
        /// [`super::sql::check_aggregate_legality`] still catches
        /// COUNT(DISTINCT *) and `STRING_AGG(DISTINCT ...)` without
        /// per-aggregate `ORDER BY` because those share the `ValueAgg`
        /// kind with their modifier-eligible siblings.
        distinct: bool,
        /// Optional user-specified window clause produced by
        /// [`super::aggregate::AggregateExpr::over`]. `None` means the
        /// aggregate has no `OVER` clause of its own; the ungrouped
        /// annotate path in `query::sql` wraps `None`-window value aggregates
        /// in `OVER ()` for backwards compatibility. Non-windowable aggregate
        /// kinds are rejected by the plain-annotation type-state before SQL
        /// emission. `Some(spec)` emits the full
        /// `OVER (PARTITION BY ... ORDER BY ... frame)` from the spec.
        window: Option<crate::expr::window::WindowSpec>,
        /// Per-aggregate `ORDER BY` clause(s). Set via
        /// [`super::aggregate::AggregateExpr::order_by`]. Empty `Vec`
        /// emits no ORDER BY; non-empty emits
        /// `AGG(arg ORDER BY <ord1>, <ord2>, ...)` (or for STRING_AGG:
        /// `STRING_AGG(arg, sep ORDER BY ...)`).
        ///
        /// Some aggregates' result depends on input order — `ARRAY_AGG`,
        /// `JSONB_AGG`, `STRING_AGG`, plus the ordered-set / hypothetical-
        /// set families (PERCENTILE_CONT, MODE, etc., per the
        /// WITHIN GROUP modifier surface that consumes this slot
        /// indirectly through the adjacent `within_group_order_by`
        /// modifier). Without this slot, callers couldn't get
        /// deterministic results from order-sensitive aggregates without
        /// wrapping the whole query in a derived table.
        ///
        /// Also unblocks `STRING_AGG(DISTINCT col, sep ORDER BY other)` —
        /// Postgres rejects that combination unless an ORDER BY is
        /// supplied; the fetch-time check in
        /// [`super::sql::check_aggregate_legality`] still rejects
        /// `STRING_AGG(DISTINCT ...)` with no ORDER BY but accepts the
        /// combination with one.
        order_by: Vec<crate::query::order::OrderExpr>,
        /// `WITHIN GROUP (ORDER BY ...)` clause for ordered-set
        /// aggregates ([`AggOp::PercentileCont`] / [`AggOp::PercentileDisc`]
        /// / [`AggOp::Mode`]) and (eventually) hypothetical-set
        /// aggregates. Empty for every other aggregate.
        ///
        /// Distinct from the per-aggregate [`order_by`](Self::Aggregate::order_by)
        /// slot — that one renders ORDER BY *inside* the aggregate's
        /// parens (`AGG(arg ORDER BY ...)` for value aggregates like
        /// `array_agg`). This slot renders ORDER BY *after* the parens
        /// in a `WITHIN GROUP` clause:
        ///
        /// ```sql
        /// PERCENTILE_CONT($1) WITHIN GROUP (ORDER BY response_ms)
        /// ```
        ///
        /// For ordered-set and hypothetical-set aggregates this slot is
        /// **mandatory**. The typed builder methods on `FieldRef` populate it
        /// at construction time from the receiver column (default ASC), and
        /// the [`super::aggregate::AggregateExpr::within_group_order_by`]
        /// modifier overrides it for the rare case where the target should
        /// differ from the constructing column or the direction should be
        /// DESC. Direct-IR construction is the only way to produce an empty
        /// slot for these ops, and debug builds assert against that bypass in
        /// [`super::sql::check_aggregate_legality`].
        ///
        /// Cluster E T7 introduced this slot. Future T8 (hypothetical-
        /// set aggregates `RANK(args) WITHIN GROUP (ORDER BY ...)`)
        /// reuses it without further IR change.
        within_group_order_by: Vec<crate::query::order::OrderExpr>,
    },

    /// `CASE WHEN <cond> THEN <val> [WHEN <cond> THEN <val> ...] ELSE
    /// <default> END` — multi-armed conditional expression.
    ///
    /// The typed builder [`super::case::Case`] / [`super::case::CaseBuilder`]
    /// is the sole construction path. Every arm is a
    /// `(condition, value)` pair — the condition is an arbitrary
    /// [`ExprNode`] that evaluates to boolean (typed as `Expr<bool>` at
    /// the builder surface), the value is the expression whose result
    /// becomes the CASE output when that arm fires. `otherwise` is
    /// **required** (not `Option`) per the Task 5 plan — forcing the
    /// user to decide on the default avoids the silent-NULL footgun
    /// where a CASE with no matching arm produces NULL against a column
    /// the user expected to be non-null.
    Case {
        /// Ordered list of `(condition, value)` pairs. Emitted as
        /// `WHEN <cond> THEN <val>` in vector order; the first arm
        /// whose condition is true wins per Postgres semantics.
        arms: Vec<(ExprNode, ExprNode)>,
        /// `ELSE <default>` expression — evaluated when no arm's
        /// condition is true. Required (no NULL default) per plan
        /// Step 3 decision.
        otherwise: Box<ExprNode>,
    },

    /// `EXISTS (<subquery>)` — boolean-valued subquery predicate.
    ///
    /// The typed surface [`super::subquery::Exists`] owns the construction
    /// path; the wrapped [`SubqueryNode`] carries the correlated
    /// queryset's table + optional `WHERE` payload. `select_column` on
    /// the node is always `None` for this variant — the emitter renders
    /// `SELECT 1` regardless of the wrapped queryset's columns because
    /// EXISTS only cares about row-presence, never scalar values.
    Exists(Box<SubqueryNode>),

    /// Scalar subquery — `(SELECT <col> FROM ... WHERE ... [LIMIT 1])`
    /// usable as any other `Expr<V>` in the outer tree.
    ///
    /// The typed surface [`super::subquery::Subquery<T, V>`] owns the
    /// construction path. `select_column` is always `Some(col)` for
    /// this variant; the emitter renders the stored column verbatim
    /// (already validated at `FieldRef::new` construction time).
    Subquery(Box<SubqueryNode>),

    /// `array_length(column, 1)` — number of elements in a 1-dimensional
    /// Postgres array column.
    ///
    /// The dimension argument is hardcoded to `1`; Djogi arrays are always
    /// 1-dimensional and multi-dimensional arrays are not a supported field
    /// type. Produces an `Expr<i32>` at the typed-wrapper layer; the emitter
    /// renders `array_length({column}, 1)`.
    ///
    /// The `column` string is a `&'static str` validated at
    /// [`crate::query::field::FieldRef::new`] construction time.
    ArrayLength { column: &'static str },

    /// `EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER` — the current calendar year
    /// as an `i32`, evaluated server-side per query.
    ///
    /// Produced by [`super::Expr::current_year`]. Composes with the existing
    /// `Expr<i32>` arithmetic IR so e.g.
    /// `Expr::current_year() - f.estimated_birth_year().as_expr()` yields the
    /// row's age as an `Expr<i32>`.
    ///
    /// # SQL emission
    ///
    /// The emitter renders the literal token stream
    /// `EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER` — no bind parameter is
    /// taken because there is no user-supplied value (the year is read from
    /// the Postgres server clock at query time). The explicit `::INTEGER`
    /// cast narrows Postgres's native `numeric` return from `EXTRACT` back
    /// to a Rust-decodable `i32` so the typed surface's `Expr<i32>` promise
    /// holds end-to-end.
    ///
    /// # Volatility note
    ///
    /// `CURRENT_DATE` is `STABLE` (not `IMMUTABLE`) per Postgres semantics —
    /// it changes by calendar day, not within a single transaction. The
    /// IR does not annotate volatility on `ExprNode` variants today; callers
    /// who need a deterministic snapshot of "now" inside a long-running
    /// transaction should bind a literal `i32` instead of using this helper.
    CurrentYear,

    /// Outer-scope column reference inside a correlated subquery.
    ///
    /// Emits the column name unqualified — Postgres resolves the name
    /// against the enclosing query scope when there is no matching
    /// column in the subquery's own `FROM` list. When both inner and
    /// outer tables expose a same-named column, the unqualified
    /// emission is ambiguous and Postgres raises `42702`. Task 5 ships
    /// the unqualified form; a qualified variant (carrying the outer
    /// table alias) is deferred alongside the broader `parent_table`
    /// threading needed for `select_related + filter_expr` composition.
    /// See the rustdoc on [`super::subquery::OuterRef`] for the caller-
    /// side workaround.
    OuterRef {
        /// The column name in the outer scope. Validated at
        /// [`super::subquery::__macro_support::__make_outer_ref`]
        /// construction time via
        /// [`crate::ident::assert_plain_ident`]; safe to push directly.
        column: &'static str,
    },

    /// Table-qualified outer-scope column reference inside a correlated
    /// subquery — emits `<table>.<column>` rather than the unqualified
    /// [`Self::OuterRef`] form.
    ///
    /// Phase 7-Zero-2 T13b introduced this variant for the macro-emitted
    /// M2M EXISTS predicate, where the inner through table and the outer
    /// peer table both carry framework `id` / `created_at` / `updated_at`
    /// columns. The unqualified form would raise `42702 column reference
    /// "id" is ambiguous`; emitting `<peer_table>.id` disambiguates.
    ///
    /// Both fields are `&'static str` validated at construction time via
    /// [`crate::ident::assert_plain_ident`]; safe to push as raw SQL.
    OuterRefColumn {
        /// Outer-scope table name (e.g. `"groups"`). Validated.
        table: &'static str,
        /// Column on that outer table (e.g. `"id"`). Validated.
        column: &'static str,
    },

    /// Alias-qualified outer-scope column reference inside a correlated
    /// subquery — emits `<alias>.<column>`. Used by LATERAL joins to
    /// unambiguously reference the outer table by its framework alias.
    OuterRefAlias {
        /// The framework alias for the outer scope (e.g. `"l"`).
        alias: &'static str,
        /// Column on that outer table. Validated.
        column: &'static str,
        /// Source model identity for this outer reference. Used to
        /// enforce that lateral inner expressions only reference the
        /// actual outer model for the current lateral join.
        model_type: TypeId,
        /// Source model diagnostic name (`type_name::<M>()`) for error
        /// messages when lateral scoping validation fails.
        model_name: &'static str,
    },

    /// `<col> @@ to_tsquery('<dictionary>', $n)` — full-text search match.
    ///
    /// Produced by `FtsFieldRef::matches(query)`. The column name is the
    /// GENERATED ALWAYS AS tsvector column (typically `"search"`). The
    /// dictionary name is embedded into the SQL as a literal token (e.g.
    /// `to_tsquery('english', $1)`) because it is a Postgres configuration
    /// name, not a user value — it came from `#[model(fts = { dictionary = "..."
    /// })]` and was validated at macro parse time. The query text is bound
    /// as a parameter.
    ///
    /// The emitter renders: `<column> @@ to_tsquery('<dictionary>', $n)`
    TsMatch {
        /// The tsvector column name (e.g. `"search"`). Validated at
        /// construction via `assert_plain_ident`; safe to push as raw SQL.
        column: &'static str,
        /// Postgres text-search config name (e.g. `"english"`). Validated
        /// at macro parse time; embedded literally into the SQL.
        dictionary: &'static str,
        /// The tsquery text bound as a parameter (e.g. `"planet & earth"`).
        query_text: String,
    },

    /// `ts_rank(<col>, to_tsquery('<dictionary>', $n))` — relevance score.
    ///
    /// Produced by `FtsFieldRef::rank(query)`. Returns an `f32` scalar
    /// that Postgres computes per-row as the document's relevance against
    /// the query. Useful in `ORDER BY ... DESC` to surface the most
    /// relevant results first.
    ///
    /// The emitter renders: `ts_rank(<column>, to_tsquery('<dictionary>', $n))`
    TsRank {
        /// The tsvector column name. Validated at construction.
        column: &'static str,
        /// Dictionary name embedded literally. Validated at macro parse time.
        dictionary: &'static str,
        /// The tsquery text bound as a parameter.
        query_text: String,
    },

    /// `ts_rank_cd(<col>, to_tsquery('<dictionary>', $n))` — cover-density rank.
    ///
    /// Like `TsRank` but uses the cover-density ranking algorithm, which
    /// weighs proximity of matching terms more heavily. Useful when term
    /// position within the document matters.
    TsRankCd {
        /// The tsvector column name.
        column: &'static str,
        /// Dictionary name.
        dictionary: &'static str,
        /// The tsquery text bound as a parameter.
        query_text: String,
    },

    // ── pg_trgm (gated on `trgm` feature) ──────────────────────────────────
    /// `<col> % $pattern` — trigram similarity predicate from the
    /// `pg_trgm` Postgres extension.
    ///
    /// The `%` operator returns `true` when the two operands' trigram
    /// similarity meets or exceeds the session GUC
    /// `pg_trgm.similarity_threshold` (Postgres default `0.3`). It is the
    /// indexable strategy member of the `gin_trgm_ops` and `gist_trgm_ops`
    /// opclasses — so the predicate is accelerated by any GIN or GiST
    /// index built with one of those opclasses over the column. The
    /// expression evaluates to `boolean`, so it fits into
    /// [`Condition::Expr(Expr<bool>)`][crate::query::condition::Condition::Expr].
    ///
    /// Adopters who need a per-query numeric threshold (instead of the
    /// session GUC) use the score-expression form
    /// [`TrgmSimilarityScore`][Self::TrgmSimilarityScore] composed inside
    /// `filter_expr` via the `Expr<T>` comparison API. That form is
    /// **not** accelerated by `gin_trgm_ops` / `gist_trgm_ops`.
    ///
    /// Column name is validated at `FieldRef` construction via
    /// `assert_plain_ident`. The pattern is always bound as a positional
    /// parameter — no user text is interpolated into the SQL fragment.
    ///
    /// Emitter renders: `<col> % $n`
    ///
    /// Gate: `#[cfg(feature = "trgm")]` — enable with
    /// `djogi = { features = ["trgm"] }`.
    #[cfg(feature = "trgm")]
    TrgmSimilarTo {
        /// The `TEXT` column — safe to push as raw SQL (validated at
        /// `FieldRef` construction via `assert_plain_ident`).
        column: &'static str,
        /// Pattern string — bound as `$n`, never interpolated into SQL.
        pattern: String,
    },

    /// `similarity(col, $pattern)` — trigram similarity score expression.
    ///
    /// Evaluates the `pg_trgm` `similarity()` function per row, returning
    /// an `f64` in `[0.0, 1.0]`. Useful as an input to `filter_expr` for
    /// per-query numeric-threshold comparisons
    /// (`expr.gte(Expr::literal(0.3_f64))`). Note that this function-form
    /// comparison is **not** accelerated by `gin_trgm_ops` /
    /// `gist_trgm_ops` — those opclasses target the operator family
    /// (`%`, `<->`, …), not arbitrary `similarity(...)` >= comparisons.
    /// For index-accelerated trgm scans, use
    /// [`TrgmSimilarTo`][Self::TrgmSimilarTo].
    ///
    /// Wider `Expr<f64>` integration (using the score in `order_by` or
    /// surfacing it via `annotate`) is a documented framework gap; see
    /// `docs/guide/trgm.md` for the current limitations.
    ///
    /// Column name is validated at `FieldRef` construction; pattern is
    /// always bound as a positional parameter.
    ///
    /// Emitter renders: `similarity(<col>, $n)`
    ///
    /// Gate: `#[cfg(feature = "trgm")]`.
    #[cfg(feature = "trgm")]
    TrgmSimilarityScore {
        /// The `TEXT` column — safe to push as raw SQL.
        column: &'static str,
        /// Pattern string — bound as `$n`, never interpolated.
        pattern: String,
    },

    /// A Postgres `INTERVAL` literal derived from a `time::Duration`.
    ///
    /// Emitted as the raw SQL token `INTERVAL '{microseconds} microseconds'` —
    /// no bind parameter is used because `tokio-postgres` / `postgres-types`
    /// does not ship a `ToSql` impl for `time::Duration` (the `time` crate's
    /// native duration type). Microseconds are faithful to `Duration`'s
    /// full sub-millisecond precision.
    ///
    /// The microsecond count is saturating-clamped to `i64` range at
    /// construction time (via [`super::literal::saturating_micros`]);
    /// Durations outside that range encode as `i64::MAX` or `i64::MIN`
    /// (~±292,277 years) rather than wrapping silently. Those extremes are
    /// already Postgres `INTERVAL` overflows, so saturation is the correct
    /// sentinel value.
    ///
    /// This variant is produced only by the `impl From<time::Duration> for
    /// Expr<time::Duration>` bridge in [`super::literal`]. User code that
    /// arrives here via `Expr::literal(duration)` has already gone through
    /// the typed `From` impl — no raw `ExprNode::IntervalLiteral {..}`
    /// construction from outside the crate is possible.
    IntervalLiteral {
        /// Microseconds — the full precision of `time::Duration` expressed
        /// as a `BIGINT`-compatible count, emitted as
        /// `INTERVAL '{microseconds} microseconds'`.
        microseconds: i64,
    },

    // ── Spatial expressions (Phase 6 `spatial` feature) ─────────────────────
    /// A spatial predicate or expression, delegating to
    /// [`super::spatial::SpatialExpr`] for SQL emission.
    ///
    /// Gated on `#[cfg(feature = "spatial")]` so builds without the feature
    /// never see any PostGIS references. The variant is only ever constructed
    /// by [`crate::query::field::FieldRef<M, GeoPoint>::within_km`] (produces
    /// `SpatialExpr::Within`, typed as `Expr<bool>`) or captured inside
    /// [`crate::query::order::OrderExpr::SpatialDistance`] (uses
    /// `SpatialExpr::Distance`, typed as `Expr<f64>`).
    ///
    /// `SpatialExpr` is `Clone + Debug`, so this variant does not break the
    /// enum's own `#[derive(Debug, Clone)]`.
    #[cfg(feature = "spatial")]
    Spatial(crate::expr::spatial::SpatialExpr),

    // ── Row-shape aggregate (Phase 8.5 Cluster F #92) ───────────────────────
    /// Row-shape aggregate function — folds a **set of rows** into a single
    /// scalar value (binary `bytea` for v0.1.0 — `ST_AsMVT` / `ST_AsGeobuf`).
    ///
    /// # Why a sibling variant, not an extension of [`Self::Aggregate`]
    ///
    /// Column aggregates take a single column expression (`AGG(col)`) and
    /// honour a modifier set (`.distinct()`, `.filter(...)`, `.over(...)`,
    /// `.order_by(...)`, `.within_group_order_by(...)`). Row aggregates take
    /// **the entire row** (`AGG(record, ...)`) and reject every modifier in
    /// that set — Postgres would error if you tried `ST_AsMVT(DISTINCT t)` or
    /// `ST_AsGeobuf(t ORDER BY ...)`. Sharing the [`Self::Aggregate`] variant
    /// would silently expose those slots through the [`super::aggregate::AggregateExpr`]
    /// modifier impl blocks; a dedicated variant keeps the kind discipline
    /// crisp.
    ///
    /// # SQL shape
    ///
    /// The emitter renders `<KEYWORD>(<row_alias>, <bind>, <bind>, …)`
    /// against a caller-supplied row alias (`__djogi_row` by convention).
    /// The outer terminal wraps the inner SELECT in a derived table whose
    /// alias matches this convention so the aggregate's record argument
    /// resolves to the full row record:
    ///
    /// ```sql
    /// SELECT ST_AsMVT(__djogi_row, $1, $2, $3, $4)
    /// FROM (
    ///     SELECT t.col1, t.col2, …, <annotations…>
    ///     FROM <table> AS t [WHERE …] [ORDER BY …] [LIMIT …]
    /// ) AS __djogi_row
    /// ```
    ///
    /// # Construction
    ///
    /// Construction is sealed behind the typed
    /// [`super::row_aggregate::RowAggregate`] wrapper; the `columns` slot is
    /// informational and reserved for future row-shape variants that need
    /// per-column projection metadata. v0.1.0 always emits the entire row
    /// alias, so the emitter does not currently inspect `columns`.
    #[cfg(feature = "spatial")]
    RowAggregate {
        /// Which row-shape aggregate function to emit (and its bindable
        /// argument payload — layer name, extent, geom column name, etc.).
        op: RowAggOp,
        /// Columns from the inner SELECT this row aggregate inspects.
        /// Reserved for future per-column-projection variants — `ST_AsMVT`
        /// / `ST_AsGeobuf` resolve column references from the record type
        /// at Postgres-runtime, so v0.1.0's emitter does not consume this
        /// slot. Kept on the variant so that adding a column-projecting
        /// row aggregate does not force an IR rev.
        ///
        /// `#[allow(dead_code)]` because the field is deliberately
        /// reserved — it is populated by every typed constructor (e.g.
        /// `crate::query::row_aggregate_terminal` pulls `T::COLUMNS`
        /// through `inner_columns_for`) so debug diagnostics can inspect
        /// the projection shape even though the emitter currently does
        /// not.
        #[allow(dead_code)]
        columns: Vec<&'static str>,
    },
}

/// Internal subquery payload — the untyped counterpart to the typed
/// [`super::subquery::Subquery<T, V>`] / [`super::subquery::Exists`]
/// wrappers.
///
/// Carries the minimum the emitter needs to render
/// `SELECT <col or 1> FROM <table> [WHERE <condition>]`:
///
/// - `table` — always `<T as Model>::table_name()` from the typed
///   surface (a `&'static str`; never user input).
/// - `select_column` — `Some(col)` for scalar subqueries (the typed
///   wrapper pins it via [`crate::query::field::FieldRef`] so the
///   identifier is always validated); `None` for EXISTS, where the
///   emitter renders `SELECT 1`.
/// - `where_clause` — the correlated predicate, stored as a
///   type-erased [`ErasedSubqueryPredicate`] handle. Phase 8eta PR2b
///   replaced the pre-flip `Option<Condition>` storage so expression
///   subqueries carry full `Q<T>` predicates — including portable root
///   field leaves — without round-tripping through `q_to_condition`.
///   The handle owns a `Q<T>` for some concrete `T: Model`; the
///   trait-object dispatch hides the model parameter so `SubqueryNode`
///   stays type-erased.
#[derive(Debug, Clone)]
pub(crate) struct SubqueryNode {
    /// Subquery's `FROM` table — `<T as Model>::table_name()` from the
    /// typed [`super::subquery::Subquery<T, V>`] surface.
    pub(crate) table: &'static str,
    /// Scalar subqueries store `Some(col)`; the EXISTS path stores
    /// `None` and the emitter renders `SELECT 1`.
    pub(crate) select_column: Option<&'static str>,
    /// The subquery's `WHERE` clause — the correlated predicate, stored
    /// as a type-erased emitter handle. `None` when the typed queryset
    /// carries no filters; the emitter skips the `WHERE` clause on that
    /// branch.
    pub(crate) where_clause: Option<ErasedSubqueryPredicate>,
}

/// Type-erased subquery predicate handle. Wraps an
/// `Arc<dyn SubqueryPredicateEmitter>` so a `SubqueryNode` can carry the
/// outer queryset's `Q<T>` predicate without leaking the model parameter
/// into the type-erased subquery wrappers.
///
/// Phase 8eta PR2b — the storage rewrite that retires the pre-flip
/// `Option<Condition>` payload. Construction goes through
/// [`Self::from_q`]; every Djogi-trusted predicate emitted from a
/// subquery flows through that path so cache/refresh boundaries can audit
/// (PR4) the trusted-portable provenance carried inside the wrapped
/// `Q<T>`.
///
/// The trait is `Send + Sync + 'static` because `ExprNode` itself is and
/// `Expr<bool>` is freely cloneable across thread boundaries.
#[derive(Clone)]
pub(crate) struct ErasedSubqueryPredicate(std::sync::Arc<dyn SubqueryPredicateEmitter>);

/// Internal trait the `ErasedSubqueryPredicate` dispatches through. Each
/// concrete implementer carries a `Q<T>` for one specific `T: Model`
/// and emits SQL through the direct walker
/// (`crate::query::sql::emit_q::<T>`).
///
/// Subquery emission threads the caller's `SqlEmitContext`. This keeps
/// lateral-scope metadata alive for nested expression/subquery emission
/// while preserving the existing column-qualification behavior: subquery
/// table qualification still flows through the IR nodes (`OuterRef`,
/// `OuterRefColumn`, `OuterRefAlias`) and regular root columns continue
/// to emit according to the caller's context.
pub(crate) trait SubqueryPredicateEmitter: Send + Sync + 'static {
    /// Emit the subquery's `WHERE` body into the outer accumulator. The
    /// outer accumulator owns global bind numbering, so binds emitted
    /// here interleave correctly with the surrounding SQL.
    fn emit(
        &self,
        acc: &mut crate::pg::accumulator::SqlAccumulator,
        ctx: crate::query::portable::SqlEmitContext,
    ) -> Result<(), crate::query::portable::PortablePredicateError>;
    /// Format the wrapped `Q<T>` for `Debug` output without requiring
    /// `T: Debug`. Delegates to `Q<T>`'s manual `Debug` impl, which
    /// itself never reaches into model-parameter clone/debug bounds.
    fn fmt_debug(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result;
}

/// Concrete implementer of [`SubqueryPredicateEmitter`]. Wraps a
/// `Q<T>` for some `T: Model`. The model parameter never leaks to the
/// surrounding `SubqueryNode` because the trait object hides it.
struct TypedSubqueryPredicate<T: crate::model::Model> {
    q: crate::query::q::Q<T>,
}

impl<T: crate::model::Model> SubqueryPredicateEmitter for TypedSubqueryPredicate<T> {
    fn emit(
        &self,
        acc: &mut crate::pg::accumulator::SqlAccumulator,
        ctx: crate::query::portable::SqlEmitContext,
    ) -> Result<(), crate::query::portable::PortablePredicateError> {
        crate::query::sql::emit_q::<T>(acc, &self.q, ctx)
    }

    fn fmt_debug(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("TypedSubqueryPredicate")
            .field(&self.q)
            .finish()
    }
}

impl ErasedSubqueryPredicate {
    /// Build an erased predicate handle from a typed `Q<T>`. The handle
    /// owns the predicate; cloning the `ExprNode` clones the underlying
    /// `Arc`, so a single `SubqueryNode` may be emitted any number of
    /// times without re-allocating the predicate.
    pub(crate) fn from_q<T: crate::model::Model>(q: crate::query::q::Q<T>) -> Self {
        Self(std::sync::Arc::new(TypedSubqueryPredicate::<T> { q }))
    }

    /// Emit this subquery predicate into the outer accumulator.
    pub(crate) fn emit(
        &self,
        acc: &mut crate::pg::accumulator::SqlAccumulator,
        ctx: crate::query::portable::SqlEmitContext,
    ) -> Result<(), crate::query::portable::PortablePredicateError> {
        self.0.emit(acc, ctx)
    }
}

impl std::fmt::Debug for ErasedSubqueryPredicate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt_debug(f)
    }
}

/// Aggregate operator — the sub-discriminant inside [`ExprNode::Aggregate`].
///
/// The [`super::aggregate::AggregateExpr<Out>`] wrapper pins the Rust
/// return type per variant (`i64` for `Count`/`CountStar`, `f64` for
/// `Avg`, the column type `V` for `Sum`/`Min`/`Max`). The emitter in
/// [`super::sql::emit_expr`] maps each variant to its SQL keyword and
/// renders `COUNT(*)` specially for [`AggOp::CountStar`] so the bare `*`
/// never flows through the identifier-validation / column-qualification
/// paths.
///
/// # Why `PartialEq` only (no `Eq`)
///
/// [`AggOp::SpatialClusterWithin`] carries an inline `f64` distance,
/// and `f64` has only [`PartialEq`] — NaN is not reflexively equal to
/// itself. The crate uses `matches!` for variant discrimination (which
/// only needs `PartialEq`), so dropping `Eq` is sound; no downstream
/// code keys an `AggOp` into a `HashMap` / `HashSet`.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum AggOp {
    /// `COUNT(col)` — returns `i64`. Counts non-null values of the
    /// argument column.
    Count,
    /// `COUNT(*)` — returns `i64`. Counts every row in the grouping
    /// (or the whole relation on an ungrouped aggregate). The
    /// [`ExprNode::Aggregate::arg`] slot is ignored on this variant;
    /// [`super::aggregate::AggregateExpr`] stores a placeholder there.
    CountStar,
    /// `SUM(col)` — returns `V` (the column's numeric type). Undefined
    /// for non-numeric columns; the typed
    /// [`super::aggregate::AggregateExpr::sum`] constructor gates this
    /// on the sealed [`super::arithmetic::Numeric`] trait.
    Sum,
    /// `AVG(col)` — returns `f64`. Postgres widens integer averages to
    /// `numeric`, but we decode to `f64` for ergonomics; callers who
    /// need `Decimal` precision use `ctx.raw_scalar` until a Phase 5
    /// `Decimal`-typed aggregate lands.
    Avg,
    /// `MIN(col)` — returns `V`. Requires the column type to be
    /// Postgres-orderable; the typed surface gates on
    /// `postgres_types::FromSql` rather than Rust `Ord` because `f64`
    /// satisfies the former but not the latter.
    Min,
    /// `MAX(col)` — returns `V`. Same ordering requirement as
    /// [`AggOp::Min`].
    Max,
    /// `ARRAY_AGG(col)` — collects non-null values into a Postgres array.
    ///
    /// Returns `Vec<V>` at the Rust level. The typed builder on
    /// [`super::aggregate::FieldRef`] pins `Out = Vec<V>` so the
    /// annotate decode path calls `row.try_get::<_, Vec<V>>(alias)`.
    /// postgres-types decodes Postgres arrays into `Vec<V>` when `V`
    /// implements `FromSql` — all scalar column types Djogi ships already
    /// satisfy that bound.
    ArrayAgg,
    /// `JSONB_AGG(col)` — aggregates rows into a JSON array, returned as
    /// `serde_json::Value`.
    ///
    /// Uses `JSONB_AGG` rather than `JSON_AGG` because Djogi standardises
    /// on JSONB for all JSON storage and wire formats (see `docs/spec/decisions.md`).
    JsonAgg,
    /// `STRING_AGG(col, sep)` — concatenates non-null string values with a
    /// separator.
    ///
    /// The separator is stored inline in the variant so the emitter can
    /// push it as a bind parameter without a separate `ExprNode`. Carrying
    /// the separator directly on the variant rather than as a second
    /// `ExprNode` child of `Aggregate { arg, .. }` keeps the existing
    /// `Aggregate` layout unchanged — no other variant needs a second
    /// operand.
    ///
    /// The separator is user-supplied at `.string_agg("sep")` call time
    /// and bound via `acc.push_bind(sep.to_string())` to avoid any risk
    /// of SQL injection from a runtime-computed separator string.
    StringAgg(String),
    /// `BOOL_AND(col)` — true if every non-null value in the column is
    /// true. Requires a boolean column; the typed builder gates on
    /// `V: Into<bool>`.
    BoolAnd,
    /// `BOOL_OR(col)` — true if at least one non-null value in the
    /// column is true. Requires a boolean column; the typed builder gates
    /// on `V: Into<bool>`.
    BoolOr,
    /// `EVERY(col)` — Postgres-standard alias for `BOOL_AND`. Both produce
    /// identical results; the alias is preserved through the IR so the
    /// emitter renders the keyword the user wrote (matching adopters'
    /// expectations from the `pg` docs / SQL standard). Same boolean column
    /// gating as [`AggOp::BoolAnd`].
    Every,
    /// `BIT_AND(col)` — bitwise AND across all non-null values, returned
    /// as the column's integer type. Postgres defines this for
    /// `SMALLINT`, `INTEGER`, `BIGINT` and bit-string types; Djogi's
    /// typed builder gates on the sealed
    /// [`super::aggregate::IntegerColumn`] trait, which admits the three
    /// integer scalar types only.
    BitAnd,
    /// `BIT_OR(col)` — bitwise OR across all non-null values. Same type
    /// gating as [`AggOp::BitAnd`].
    BitOr,
    /// `BIT_XOR(col)` — bitwise XOR across all non-null values. Same
    /// type gating as [`AggOp::BitAnd`]. PostgreSQL 14+ feature; Djogi's
    /// floor (PostgreSQL 18) safely supports it.
    BitXor,
    /// `STDDEV_POP(col)` — population standard deviation, returned as
    /// `f64` (cast to `DOUBLE PRECISION` to honour the typed surface's
    /// `Out = f64` promise across integer and float inputs).
    StddevPop,
    /// `STDDEV_SAMP(col)` — sample standard deviation. Postgres' default
    /// "stddev" is the sample form; both spellings are exposed.
    StddevSamp,
    /// `STDDEV(col)` — Postgres alias for [`AggOp::StddevSamp`]. Carried
    /// as a distinct variant so the emitter preserves the spelling the
    /// caller used (matching the `every` / `bool_and` alias treatment).
    Stddev,
    /// `VAR_POP(col)` — population variance, returned as `f64`.
    VarPop,
    /// `VAR_SAMP(col)` — sample variance.
    VarSamp,
    /// `VARIANCE(col)` — Postgres alias for [`AggOp::VarSamp`].
    Variance,
    /// `COVAR_POP(y, x)` — population covariance, returned as `f64`.
    /// Two-arg aggregate: `arg` carries `y`, `arg2` carries `x`.
    /// `y` first matches Postgres convention (the dependent variable
    /// is the first argument across the regression / covariance family).
    CovarPop,
    /// `COVAR_SAMP(y, x)` — sample covariance, returned as `f64`. Same
    /// arg ordering as [`AggOp::CovarPop`].
    CovarSamp,
    /// `CORR(y, x)` — Pearson correlation coefficient, returned as
    /// `f64`. Same arg ordering as [`AggOp::CovarPop`].
    Corr,
    /// `REGR_AVGX(y, x)` — average of the independent variable across
    /// rows where both columns are non-null. Returned as `f64`.
    RegrAvgx,
    /// `REGR_AVGY(y, x)` — average of the dependent variable across
    /// rows where both columns are non-null. Returned as `f64`.
    RegrAvgy,
    /// `REGR_COUNT(y, x)` — number of input rows where both columns
    /// are non-null. Postgres returns `BIGINT` here (unlike the rest
    /// of the regression family which returns `DOUBLE PRECISION`); the
    /// typed surface returns `AggregateExpr<i64>` accordingly.
    RegrCount,
    /// `REGR_INTERCEPT(y, x)` — y-intercept of the least-squares-fit
    /// line through the (y, x) pairs. Returned as `f64`.
    RegrIntercept,
    /// `REGR_R2(y, x)` — coefficient of determination of the
    /// least-squares-fit line. Returned as `f64`.
    RegrR2,
    /// `REGR_SLOPE(y, x)` — slope of the least-squares-fit line
    /// through the (y, x) pairs. Returned as `f64`.
    RegrSlope,
    /// `REGR_SXX(y, x)` — sum of squares of the independent variable
    /// (sum of `(x - avg(x))^2`). Returned as `f64`.
    RegrSxx,
    /// `REGR_SXY(y, x)` — sum of products of (y, x) deviations
    /// (sum of `(x - avg(x)) * (y - avg(y))`). Returned as `f64`.
    RegrSxy,
    /// `REGR_SYY(y, x)` — sum of squares of the dependent variable
    /// (sum of `(y - avg(y))^2`). Returned as `f64`.
    RegrSyy,
    /// `JSON_OBJECT_AGG(key, value)` — builds a `json` object from
    /// per-row key/value pairs. `arg` carries the key column, `arg2`
    /// carries the value column. Returns `serde_json::Value` at the
    /// typed surface.
    ///
    /// Distinct from [`AggOp::JsonbObjectAgg`] in the Postgres return
    /// type (`json` vs `jsonb`); both are exposed because adopters
    /// needing JSON output (e.g. for an external consumer that cannot
    /// handle JSONB) have no other path today.
    JsonObjectAgg,
    /// `JSONB_OBJECT_AGG(key, value)` — `jsonb` variant of
    /// [`AggOp::JsonObjectAgg`]. Same shape, different Postgres return
    /// type. Djogi standardises on JSONB elsewhere (see
    /// `docs/spec/decisions.md`); this variant is the recommended
    /// default.
    JsonbObjectAgg,
    /// `GROUPING(col)` — Postgres group-set helper that returns `1` if
    /// the column was rolled up in the current result row (i.e. it is
    /// a subtotal row from `ROLLUP` / `CUBE` / `GROUPING SETS`), `0`
    /// otherwise. Returns `INTEGER` at the Postgres level; the typed
    /// surface decodes into `i32`.
    ///
    /// Single-column form. The variadic `GROUPING(c1, …, cN)` ships
    /// through the dedicated [`ExprNode::GroupingVariadic`] variant
    /// rather than reshaping this op's arg slot.
    Grouping,
    /// `PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY col)` — continuous
    /// percentile (interpolating between adjacent values when the
    /// requested fraction falls between two rows). Returns
    /// `DOUBLE PRECISION` (cast to `f64` at the typed surface).
    ///
    /// Ordered-set aggregate — the fraction `p` lives in the `arg` slot
    /// as a literal `f64`; the column being percentiled lives in the
    /// `within_group_order_by` slot. WITHIN GROUP is mandatory and is
    /// populated by the typed ordered-set constructor. DISTINCT and the
    /// per-aggregate `order_by` slot are not exposed on
    /// `AggregateExpr<Out, OrderedSetAgg>`; debug builds assert the same
    /// invariants for direct-IR construction.
    PercentileCont,
    /// `PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY col)` — discrete
    /// percentile (returns the actual value at the percentile cut, no
    /// interpolation). Return type matches the column type — the typed
    /// `AggregateExpr<V>` carries `V = column type`.
    ///
    /// Same shape as [`AggOp::PercentileCont`] otherwise — fraction in
    /// `arg`, column in `within_group_order_by`.
    PercentileDisc,
    /// `MODE() WITHIN GROUP (ORDER BY col)` — most common value in the
    /// group. Returns the column type (typed `AggregateExpr<V>` with
    /// `V = column type`).
    ///
    /// `MODE()` takes no function arguments; the `arg` slot stores a
    /// sentinel `Field { column: \"\" }` placeholder which the emitter
    /// ignores on this branch (parallel to how `CountStar` ignores
    /// `arg`). The column being mode-aggregated lives in
    /// `within_group_order_by`.
    Mode,
    /// `RANK(value) WITHIN GROUP (ORDER BY col)` — hypothetical-set
    /// rank: the rank that `value` would have if inserted into the
    /// sorted column. Returns `BIGINT` (typed as `i64`).
    ///
    /// Distinct from the window-only rank function in
    /// [`super::window_fn::Rank`]: that one ranks each row within a
    /// PARTITION/ORDER window, while this one answers "what rank
    /// would this hypothetical value have?" given a single ordered
    /// set. Same modifier discipline as the ordered-set aggregates —
    /// no DISTINCT, no in-paren ORDER BY, mandatory WITHIN GROUP.
    HypotheticalRank,
    /// `DENSE_RANK(value) WITHIN GROUP (ORDER BY col)` — hypothetical-
    /// set dense rank (no gaps in rank numbering when ties occur).
    /// Returns `BIGINT`.
    HypotheticalDenseRank,
    /// `PERCENT_RANK(value) WITHIN GROUP (ORDER BY col)` —
    /// hypothetical-set percent rank: the position the hypothetical
    /// value would have as a fraction in `[0.0, 1.0]`. Returns
    /// `DOUBLE PRECISION`.
    HypotheticalPercentRank,
    /// `CUME_DIST(value) WITHIN GROUP (ORDER BY col)` — hypothetical-
    /// set cumulative distribution: the fraction of rows that would
    /// rank at or below the hypothetical value. Returns
    /// `DOUBLE PRECISION`.
    HypotheticalCumeDist,
    /// `ST_Centroid(ST_Collect(<col>))::geography` — per-group centroid
    /// of point geometries. Fused two-call shape (the emitter wraps
    /// `ST_Collect` inside `ST_Centroid` and casts back to geography).
    /// Returns `GeoPoint`. Gated on `feature = "spatial"`.
    ///
    /// Sibling of [`AggOp::SpatialConvexHull`] — same wrapped emission
    /// shape (`WRAP(ST_Collect(...))::geography`) and same modifier
    /// composition story.
    #[cfg(feature = "spatial")]
    SpatialCentroid,
    /// `ST_ConvexHull(ST_Collect(<col>::geometry))::geography` —
    /// per-group convex-hull aggregate. Migrated from
    /// `ExprNode::Spatial(SpatialExpr::ConvexHull{..})` in Cluster E
    /// round-5: the old IR shape silently dropped `.distinct()` /
    /// `.filter()` / `.over()` / `.order_by()` modifiers because
    /// those mutate `ExprNode::Aggregate` only. As a proper AggOp
    /// variant ConvexHull now composes uniformly with the rest of
    /// the aggregate family.
    ///
    /// Wrapped emission: ST_Collect is the actual aggregate;
    /// ST_ConvexHull is a scalar wrapper applied to the collected
    /// geometry set per group. Same shape as
    /// [`AggOp::SpatialCentroid`].
    #[cfg(feature = "spatial")]
    SpatialConvexHull,
    /// `ST_Collect(<col>)::geography` — per-group multi-geometry
    /// collection. Returns a `MultiPoint` for `GeoPoint` inputs (and
    /// the corresponding multi-shape for other geography inputs once
    /// the typed surface extends to non-point geographies).
    /// Sibling of [`AggOp::SpatialCentroid`].
    #[cfg(feature = "spatial")]
    SpatialCollect,
    /// `ST_Union(<col>::geometry)::geography` — per-group region-merging
    /// aggregate. Folds polygonal inputs into a single combined region.
    /// Returns a `MultiPolygon` — Djogi's typed surface restricts the
    /// receiver to polygon-shaped fields (`Polygon`, `MultiPolygon`) so
    /// the decode is sound; point-shaped inputs use [`AggOp::SpatialCollect`]
    /// (T12's `collect()`) instead. Gated on `feature = "spatial"`.
    #[cfg(feature = "spatial")]
    SpatialUnion,
    /// `ST_Extent(<col>::geometry)::geometry::geography` — per-group 2D
    /// bounding-box aggregate. Postgres returns the special `box2d` type
    /// which Djogi casts through `geometry` (yielding a four-vertex
    /// rectangle Polygon) and back to `geography` for the typed decode.
    /// Returns `Polygon`. Gated on `feature = "spatial"`.
    ///
    /// The `box2d::geometry::geography` cast chain is well-defined
    /// PostGIS — the geometry-side cast produces a polygon footprint,
    /// and the geography-side cast keeps the value on the geography
    /// substrate so adopters get back a `Polygon` they can decompose
    /// with the existing geometry surface.
    #[cfg(feature = "spatial")]
    SpatialExtent,
    /// `ST_3DExtent(<col>::geometry)::geometry::geography` — per-group
    /// 3D bounding-box aggregate. Same cast chain as
    /// [`AggOp::SpatialExtent`] but the underlying Postgres type is
    /// `box3d`; the geometry-side cast projects the 3D box to its 2D
    /// polygon footprint, and the geography-side cast keeps the value
    /// on the geography substrate. Returns `Polygon`.
    /// Gated on `feature = "spatial"`.
    ///
    /// Adopters with true 3D data should reach for `ctx.raw_scalar`
    /// against the `box3d` type directly — Djogi's typed geography
    /// surface is 2D-only.
    #[cfg(feature = "spatial")]
    SpatialExtent3D,
    /// `ST_MakeLine(<col>::geometry)::geography` — per-group
    /// LineString builder. Connects per-row points into a single
    /// LineString in row order, or per-aggregate ORDER BY order when
    /// `.order_by(field)` is chained. Returns `LineString`.
    /// Gated on `feature = "spatial"`.
    ///
    /// Order-sensitive: the resulting line's vertex sequence follows
    /// row order, so this aggregate naturally consumes T1's
    /// `.order_by(field)` modifier — the per-aggregate ORDER BY
    /// clause lands inside the `ST_MakeLine` parens to control
    /// vertex sequence at the aggregate level.
    ///
    /// Sibling [`AggOp::SpatialLineAgg`] (Cluster E T14b) handles the
    /// "collect already-existing LineStrings into a MultiLineString"
    /// use case once the `MultiLineString` geo type lands.
    #[cfg(feature = "spatial")]
    SpatialMakeLine,
    /// `ST_LineAgg(<col>::geometry)::geography` — per-group
    /// `MultiLineString` builder. Collects per-row `LineString` values
    /// into a single `MultiLineString`. Returns `MultiLineString`.
    /// Gated on `feature = "spatial"`.
    ///
    /// `ST_LineAgg` is PostgreSQL 17+ / PostGIS 3.5+; on earlier
    /// installations the equivalent shape is
    /// `ST_LineFromMultiPoint(ST_Collect(<col>))` for a multipoint
    /// input. Djogi targets PG 18 + PostGIS 3.5, so the canonical
    /// `ST_LineAgg` keyword is the safe choice. If a future
    /// installation drift surfaces, the emitter arm is the single
    /// migration site.
    ///
    /// Cluster E T14b retroactively shipped this aggregate after
    /// Track A's initial deferral — no arbitrary deferrals per
    /// `feedback_no_arbitrary_deferrals.md`.
    #[cfg(feature = "spatial")]
    SpatialLineAgg,
    /// `ST_Collect(<col>::geometry)::geography` — per-group polygon
    /// collection (portable fallback for `ST_PolygonAgg`). Returns
    /// `MultiPolygon`. Gated on `feature = "spatial"`.
    ///
    /// `ST_PolygonAgg` is PostGIS 3.5+; Djogi's documented PostGIS
    /// floor is 3.x (see `docs/guide/spatial.md`), so the emitter
    /// uses the portable `ST_Collect` form which produces an
    /// equivalent MultiPolygon for polygon-typed inputs. The
    /// distinct AggOp variant carries the intent (callers asked for
    /// `polygon_agg()` semantics, not the more general `collect()`)
    /// and keeps a clean migration path if Djogi ever raises the
    /// PostGIS floor — only the emitter arm changes.
    #[cfg(feature = "spatial")]
    SpatialPolygonAgg,
    /// `ST_ClusterIntersecting(<col>::geometry)::geography[]` — per-
    /// group clustering aggregate that groups input geometries which
    /// mutually intersect into per-cluster collections. Returns
    /// `geometry[]` at the Postgres level; Djogi casts the array
    /// element type to `geography` so the typed surface decodes into
    /// `Vec<MultiPolygon>`. Gated on `feature = "spatial"`.
    ///
    /// Available on polygon-shaped fields only — the typed return
    /// `Vec<MultiPolygon>` matches the natural PostGIS output for
    /// polygonal inputs. Point-shaped inputs produce `Vec<MultiPoint>`
    /// at the Postgres level which would break the typed decode;
    /// adopters wanting clustering semantics over points reach for
    /// the existing window-function `cluster_by_proximity`.
    #[cfg(feature = "spatial")]
    SpatialClusterIntersecting,
    /// `ST_ClusterWithin(<col>::geometry, $1)::geography[]` — per-
    /// group clustering aggregate that groups input geometries within
    /// `distance` meters of each other. The distance is carried inline
    /// on the variant (matching [`AggOp::StringAgg`]'s separator pattern)
    /// and bound as a parameter at emission. Returns
    /// `Vec<MultiPolygon>`. Gated on `feature = "spatial"`.
    ///
    /// Same receiver-shape gating as
    /// [`AggOp::SpatialClusterIntersecting`] — polygon-shaped fields
    /// only.
    #[cfg(feature = "spatial")]
    SpatialClusterWithin(f64),
    /// `ST_MemUnion(<col>::geometry)::geography` — memory-friendly
    /// pairwise-merge variant of [`AggOp::SpatialUnion`]. Same input/
    /// output shape (both fold polygonal inputs into a single
    /// MultiPolygon by merging shared edges); different algorithm.
    /// `ST_Union` sorts inputs and merges along a shared edge tree;
    /// `ST_MemUnion` runs a pairwise merge that uses bounded working
    /// memory but is slower per-row for moderate input sizes.
    /// Returns `MultiPolygon`. Gated on `feature = "spatial"`.
    ///
    /// Adopters with terabyte-scale polygonal inputs use `mem_union()`;
    /// for moderate group sizes [`AggOp::SpatialUnion`] is faster.
    #[cfg(feature = "spatial")]
    SpatialMemUnion,
    /// `ST_Polygonize(<col>::geometry)::geography` — builds polygons
    /// from a per-group set of LineString segments. PostGIS returns a
    /// GeometryCollection at the geometry level; the geography-substrate
    /// cast lets the typed surface decode it as `MultiPolygon` for the
    /// typical line-segments-to-region case. Gated on
    /// `feature = "spatial"`.
    ///
    /// Only available on `LineString` fields — the input must be a
    /// collection of edges for the polygonization algorithm to produce
    /// sensible output. The receiver-type gate enforces this at the
    /// impl-block level.
    #[cfg(feature = "spatial")]
    SpatialPolygonize,
}

/// Row-shape aggregate operator — the sub-discriminant inside
/// [`ExprNode::RowAggregate`]. Each variant pins the SQL keyword the emitter
/// renders plus the bindable argument payload that variant's PostGIS function
/// accepts.
///
/// Both variants are PostGIS-only and therefore gated on `feature = "spatial"`.
/// The enum itself is `pub(crate)` — construction is sealed behind the typed
/// [`super::row_aggregate::RowAggregate`] wrapper, which the
/// [`crate::query::annotate::AnnotatedQuerySet`] /
/// [`crate::query::queryset::QuerySet`] terminal builders use to keep adopter
/// code from fabricating row aggregates with hostile bind payloads.
///
/// # Why `PartialEq` only (no `Eq`)
///
/// Parallel rationale to [`AggOp`]: variants carry `String` payloads (cheap
/// to compare for equality) but the enum has no requirement to key into a
/// `HashMap` / `HashSet`. Mirroring [`AggOp`]'s `PartialEq`-only stance keeps
/// the two enums shape-compatible for shared emitter helpers.
#[cfg(feature = "spatial")]
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RowAggOp {
    /// `ST_AsMVT(row, layer_name, extent, geom_name, feature_id_name)` —
    /// Mapbox Vector Tile encoder. Returns `bytea`; the typed surface
    /// decodes into `Vec<u8>`.
    ///
    /// All four trailing arguments are bound through the SQL accumulator so
    /// a runtime-computed layer name cannot SQL-inject into the emission
    /// stream. PostGIS defaults are `'default'` / `4096` / `'geom'` /
    /// `NULL`; the typed surface always emits the layer name + extent +
    /// geom_name explicitly (no SQL-side default reliance) and emits the
    /// feature_id argument only when set so the call falls through to the
    /// Postgres-side `NULL` default when omitted.
    AsMvt {
        /// MVT layer name — appears in the encoded protobuf as the layer
        /// identifier. Always bound as `text`, never spliced as SQL.
        layer_name: String,
        /// Tile extent in MVT coordinate units. PostGIS default is `4096`;
        /// the typed surface caps the value at `i32` because PostGIS's
        /// `ST_AsMVT` signature takes `integer`.
        extent: i32,
        /// Name of the geometry column in the inner row that PostGIS
        /// should treat as the feature geometry. The column must exist in
        /// the inner SELECT and decode as PostGIS `geometry` / `geography`.
        geom_name: String,
        /// Optional name of the column to encode as the MVT feature id.
        /// `None` falls through to PostGIS's `NULL` default (no feature id
        /// — features are rendered anonymously).
        feature_id_name: Option<String>,
    },
    /// `ST_AsGeobuf(row, geom_name)` — Geobuf encoder. Returns `bytea`; the
    /// typed surface decodes into `Vec<u8>`.
    ///
    /// PostGIS's Geobuf surface accepts a single trailing geometry-column
    /// argument; v0.1.0's typed surface always emits it explicitly so the
    /// emission shape stays stable across PostGIS releases that might
    /// rev the default.
    AsGeobuf {
        /// Name of the geometry column in the inner row. Same role as
        /// [`Self::AsMvt::geom_name`].
        geom_name: String,
    },
}

/// Comparison operator — the sub-discriminant inside [`ExprNode::Cmp`].
///
/// Mirrors the subset of [`crate::query::condition::LookupOp`] that takes
/// two expression operands (not, e.g., `IS NULL` which takes one). The
/// emitter maps each variant to the corresponding SQL token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CmpOp {
    /// `lhs = rhs`
    Eq,
    /// `lhs <> rhs`
    Neq,
    /// `lhs > rhs`
    Gt,
    /// `lhs >= rhs`
    Gte,
    /// `lhs < rhs`
    Lt,
    /// `lhs <= rhs`
    Lte,
    /// `lhs IS DISTINCT FROM rhs`
    IsDistinctFrom,
    /// `lhs IS NOT DISTINCT FROM rhs`
    IsNotDistinctFrom,
}