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
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
//! Crate-private SQL identifier validation.
//!
//! This module is the single source of truth for identifier validation
//! across every djogi SQL emitter. Any macro-emitted `&'static str` that
//! eventually lands inside an `SqlAccumulator::push_sql` call — a relation
//! path's `source_column` / `target_table`, a `FieldRef`'s column name, a
//! field descriptor's `name` — goes through [`assert_plain_ident`] first so
//! broken macro emissions and hostile downstream bypass attempts produce
//! a framework-bug panic instead of malformed SQL.
//!
//! The validator enforces the Postgres unquoted-identifier contract:
//!
//! 1. Non-empty.
//! 2. Length ≤ 63 bytes (`NAMEDATALEN - 1`), so Rust-level and
//!    Postgres-level identifier identity cannot diverge through
//!    server-side truncation.
//! 3. First byte is an ASCII letter or underscore; every remaining
//!    byte is ASCII alphanumeric or underscore. Djogi additionally
//!    rejects the `$` byte that Postgres tolerates in unquoted
//!    identifiers, to keep the class trivial to reason about.
//!    (Implementation is pure `u8::is_ascii_alphabetic` /
//!    `u8::is_ascii_alphanumeric` — no regex engine, no dependency.)
//! 4. Not a reserved Postgres keyword (case-insensitive; catcode `R`
//!    in `pg_get_keywords()` as of Postgres 18).
//!
//! Callers that emit literals from `#[derive(Model)]` are the intended
//! audience; the panic messages read as framework bugs or bypass attempts.

/// Postgres's usable identifier length — `NAMEDATALEN - 1` on a default
/// build. Identifiers longer than this are silently truncated by the
/// server, which would let two Rust-distinct identifiers collide after
/// the first 63 bytes. Rejecting up front keeps the Rust- and SQL-level
/// identity contracts aligned.
pub(crate) const MAX_IDENT_LEN: usize = 63;

/// Framework-reserved identifier prefix.
///
/// Djogi reserves identifiers beginning with `__djogi_` (two leading
/// underscores + ASCII-case-insensitive `djogi` + underscore) for
/// framework-internal use — recursive CTE names (`__djogi_tree`,
/// `__djogi_closure`),
/// derived-table aliases (`__djogi_q`), synthetic column slots
/// (`__djogi_agg_N`, `__djogi_parent_id`, `__djogi_search_seq`,
/// `__djogi_edge_label`), and macro scratch identifiers. See
/// `docs/spec/reserved-identifiers.md` for the inventory and rationale.
///
/// User-supplied identifiers — window aliases, FTS dictionary names,
/// closure-model column accessors, outbox table names, runtime enum
/// types — must not enter this namespace, since the framework can
/// repurpose any name in it without considering it a breaking change.
/// Macro-emitted identifiers from `#[derive(Model)]` and friends *do*
/// legitimately start with `__djogi_`, so the runtime
/// [`assert_plain_ident`] / [`check_plain_ident`] validators do not
/// enforce the rule by themselves; the user-facing pair
/// [`assert_user_supplied_ident`] / [`check_user_supplied_ident`]
/// layers it on for surfaces that route adopter input into framework
/// SQL.
pub(crate) const RESERVED_DJOGI_PREFIX: &[u8] = b"__djogi_";

/// Const-stable check for the [`RESERVED_DJOGI_PREFIX`] namespace.
///
/// Equivalent to a case-insensitive `bytes.starts_with("__djogi_")`
/// over the alphabetic segment, but written with const-stable indexing
/// primitives so it can fire from [`const_assert_user_supplied_ident`]
/// inside `inventory::submit!` initializers.
pub(crate) const fn starts_with_reserved_djogi_prefix(bytes: &[u8]) -> bool {
    if bytes.len() < RESERVED_DJOGI_PREFIX.len() {
        return false;
    }
    let mut i = 0;
    while i < RESERVED_DJOGI_PREFIX.len() {
        let actual = if bytes[i] >= b'A' && bytes[i] <= b'Z' {
            bytes[i] | 0x20
        } else {
            bytes[i]
        };
        if actual != RESERVED_DJOGI_PREFIX[i] {
            return false;
        }
        i += 1;
    }
    true
}

/// Postgres fully-reserved keywords (catcode `R` in `pg_get_keywords()`
/// as of Postgres 18). These cannot be used as identifiers unless
/// quoted, so emitting them unquoted into `SELECT`, `FROM`, or
/// `LEFT JOIN` produces a parse error — turning a hostile downstream
/// call into a broken-SQL vector. Entries are lowercase and sorted for
/// `binary_search`; [`reserved_keywords_is_sorted_and_lowercase`] pins
/// both invariants in tests so a later edit cannot silently break
/// lookup.
const RESERVED_KEYWORDS: &[&str] = &[
    "all",
    "analyse",
    "analyze",
    "and",
    "any",
    "array",
    "as",
    "asc",
    "asymmetric",
    "both",
    "case",
    "cast",
    "check",
    "collate",
    "column",
    "constraint",
    "create",
    "current_catalog",
    "current_date",
    "current_role",
    "current_time",
    "current_timestamp",
    "current_user",
    "default",
    "deferrable",
    "desc",
    "distinct",
    "do",
    "else",
    "end",
    "except",
    "false",
    "fetch",
    "for",
    "foreign",
    "from",
    "grant",
    "group",
    "having",
    "in",
    "initially",
    "intersect",
    "into",
    "lateral",
    "leading",
    "limit",
    "localtime",
    "localtimestamp",
    "not",
    "null",
    "offset",
    "on",
    "only",
    "or",
    "order",
    "placing",
    "primary",
    "references",
    "returning",
    "select",
    "session_user",
    "some",
    "symmetric",
    "system_user",
    "table",
    "then",
    "to",
    "trailing",
    "true",
    "union",
    "unique",
    "user",
    "using",
    "variadic",
    "when",
    "where",
    "window",
    "with",
];

/// Compile-time-evaluable identifier check.
///
/// Mirrors the four rules in [`assert_plain_ident`] but implemented
/// with exclusively const-stable primitives so the result can panic
/// during `const` evaluation — notably inside `inventory::submit!` on
/// the reverse-relation registry path, where the macro must produce a
/// `static` initializer.
///
/// `role` is the same label used by [`assert_plain_ident`] so panic
/// messages stay identical whether the check fires at const-eval or
/// runtime. Keyword lookup is a const-time linear scan of the sorted
/// [`RESERVED_KEYWORDS`] slice (const slice indexing and byte
/// comparison are stable; `slice::binary_search` is not yet const).
/// The linear scan is O(77) — a handful of microseconds per call at
/// build time and zero cost at runtime.
pub(crate) const fn const_assert_plain_ident(value: &'static str, role: &'static str) {
    // Mirror the runtime validator step-for-step. Each panic body uses
    // plain string literals (no `format_args!`) because const-panic
    // cannot format with runtime arguments — the messages drop the
    // offending value but keep the `role` hook for triage. The runtime
    // `assert_plain_ident` still emits full diagnostics with `value`
    // interpolated; both paths share the same four rules.
    let bytes = value.as_bytes();
    assert!(
        !bytes.is_empty(),
        // `role` is &'static str but can't be interpolated in const-panic;
        // a generic panic message is still actionable because the call
        // site pinpoints the emission.
        "djogi::ident: macro-emitted identifier must not be empty — this is a framework bug"
    );
    assert!(
        bytes.len() <= MAX_IDENT_LEN,
        "djogi::ident: macro-emitted identifier exceeds Postgres's 63-byte usable length \
         (NAMEDATALEN - 1) — either the proc-macro emission is broken or downstream code \
         bypassed the macro-support seal"
    );
    assert!(
        bytes[0].is_ascii_alphabetic() || bytes[0] == b'_',
        "djogi::ident: macro-emitted identifier must start with a letter or underscore \
         — either the proc-macro emission is broken or downstream code bypassed the \
         macro-support seal"
    );
    // Const-friendly indexed byte scan — `for` over a slice works in
    // const fn on current stable but requires indexing, not iteration
    // adapters like `all` / `iter().skip(1)`.
    let mut i = 1;
    while i < bytes.len() {
        let byte = bytes[i];
        assert!(
            byte.is_ascii_alphanumeric() || byte == b'_',
            "djogi::ident: macro-emitted identifier contains a non-identifier character \
             — either the proc-macro emission is broken or downstream code bypassed the \
             macro-support seal"
        );
        i += 1;
    }
    // Reserved-keyword lookup. We can't call `slice::binary_search` or
    // `str::to_ascii_lowercase` in const context, so the scan is
    // linear and case-folds byte-by-byte inline. Every byte is ASCII
    // alphanumeric or `_` by the preceding checks, so the case-fold
    // is just "toggle bit 0x20 on ASCII uppercase".
    let mut k = 0;
    while k < RESERVED_KEYWORDS.len() {
        let kw = RESERVED_KEYWORDS[k].as_bytes();
        if const_eq_ignore_ascii_case(bytes, kw) {
            // Const-panic path — `panic!` (not `assert!(false, ...)`)
            // both satisfies `clippy::assertions_on_constants` and
            // matches the "unreachable under a well-formed emission"
            // intent of the seal.
            panic!(
                "djogi::ident: macro-emitted identifier is a reserved Postgres keyword and \
                 cannot appear unquoted in generated SQL — either the proc-macro emission \
                 is broken or downstream code bypassed the macro-support seal"
            );
        }
        k += 1;
    }
    // `role` is threaded in for diagnostic parity with the runtime
    // validator; the actual string does not participate in any check.
    // Touching it keeps `#[allow(unused)]` off the signature and
    // preserves the call shape if a future const-panic feature grows
    // interpolation.
    let _ = role;
}

/// Compile-time-evaluable validator for user-supplied identifiers.
///
/// Adds the framework-reserved-prefix block from
/// [`check_user_supplied_ident`] to [`const_assert_plain_ident`]. Use
/// this for macro arguments that originate in adopter code and are
/// baked into SQL-facing strings or macro-expanded relation names.
/// Keep using [`const_assert_plain_ident`] for framework-generated
/// internal identifiers that legitimately live in the `__djogi_*`
/// namespace.
pub(crate) const fn const_assert_user_supplied_ident(value: &'static str, role: &'static str) {
    if starts_with_reserved_djogi_prefix(value.as_bytes()) {
        panic!(
            "djogi::ident: user-supplied identifier starts with the framework-reserved \
             `__djogi_` prefix"
        );
    }
    const_assert_plain_ident(value, role);
}

/// Const-stable case-insensitive byte-slice comparison.
///
/// Works on ASCII-only inputs — every caller here has already
/// passed the alphanumeric-or-underscore byte check in
/// [`const_assert_plain_ident`]. Equivalent to
/// `a.eq_ignore_ascii_case(b)` but usable in const context.
const fn const_eq_ignore_ascii_case(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut i = 0;
    while i < a.len() {
        // Lowercase both bytes with the ASCII bit-flip trick. Only
        // touches 'A'..='Z' (0x41..=0x5A) and 'a'..='z' (0x61..=0x7A);
        // underscores and digits are fixed under the flip.
        let la = if a[i] >= b'A' && a[i] <= b'Z' {
            a[i] | 0x20
        } else {
            a[i]
        };
        let lb = if b[i] >= b'A' && b[i] <= b'Z' {
            b[i] | 0x20
        } else {
            b[i]
        };
        if la != lb {
            return false;
        }
        i += 1;
    }
    true
}

/// Why a single rule failed when [`check_plain_ident`] (or its
/// user-supplied twin [`check_user_supplied_ident`]) returns `Err`.
///
/// Each variant carries enough payload that the caller can render its
/// preferred error shape (`DjogiError`, `String`, panic message)
/// without re-walking the bytes. `Reserved` and `ReservedDjogiPrefix`
/// carry no payload because the caller already has the offending
/// name; the lookups happened against the lowercased copy / against
/// the [`RESERVED_DJOGI_PREFIX`] constant respectively.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdentError {
    /// Empty input.
    Empty,
    /// Exceeded `MAX_IDENT_LEN` bytes — payload is the offending length.
    TooLong { len: usize },
    /// First byte is not an ASCII letter or underscore.
    BadFirst { byte: u8 },
    /// A byte after the first is not ASCII alphanumeric or underscore.
    BadByte { idx: usize, byte: u8 },
    /// Lowercased name is in the [`RESERVED_KEYWORDS`] table.
    Reserved,
    /// Name starts with the framework-reserved [`RESERVED_DJOGI_PREFIX`]
    /// (`__djogi_`, ASCII-case-insensitive). Only emitted by
    /// [`check_user_supplied_ident`]; the
    /// general-purpose [`check_plain_ident`] continues to accept this
    /// prefix because macro-emitted identifiers legitimately use it.
    ReservedDjogiPrefix,
}

/// Fallible validator for runtime-supplied identifiers — the
/// `Result`-returning twin of [`assert_plain_ident`]. Returns an
/// [`IdentError`] describing which rule failed; the caller maps it
/// onto its preferred error type.
///
/// `check_reserved` toggles the reserved-keyword lookup. Macro-time
/// callers and SQL-emission paths set it to `true`; runtime-helper
/// callers (where wrapping in a `DO`-block makes reserved words like
/// `interval` legal) pass `false`.
///
/// Cluster 4 absorbed six near-duplicate inline validators (outbox /
/// context / fts dictionary / fts source column / jsonb path) that
/// each re-encoded these rules; this function is the single source of
/// truth.
pub(crate) fn check_plain_ident(value: &str, check_reserved: bool) -> Result<(), IdentError> {
    let bytes = value.as_bytes();
    if bytes.is_empty() {
        return Err(IdentError::Empty);
    }
    if bytes.len() > MAX_IDENT_LEN {
        return Err(IdentError::TooLong { len: bytes.len() });
    }
    let first = bytes[0];
    if !(first.is_ascii_alphabetic() || first == b'_') {
        return Err(IdentError::BadFirst { byte: first });
    }
    for (i, &byte) in bytes.iter().enumerate().skip(1) {
        if !(byte.is_ascii_alphanumeric() || byte == b'_') {
            return Err(IdentError::BadByte { idx: i, byte });
        }
    }
    if check_reserved {
        // Stack-allocated lowercase for the reserved-keyword lookup —
        // mirrors the buffer in `assert_plain_ident`.
        let mut lower_buf = [0u8; MAX_IDENT_LEN + 1];
        let len = bytes.len();
        lower_buf[..len].copy_from_slice(bytes);
        lower_buf[..len].make_ascii_lowercase();
        let lower = std::str::from_utf8(&lower_buf[..len])
            .expect("ASCII identifier must be valid UTF-8 after lowercasing");
        if RESERVED_KEYWORDS.binary_search(&lower).is_ok() {
            return Err(IdentError::Reserved);
        }
    }
    Ok(())
}

/// Validate a macro-emitted identifier against the Postgres unquoted-
/// identifier contract. Panics on the first rule violation. See the
/// module-level doc for the four rules.
///
/// `role` labels the identifier in the panic message (e.g.
/// `"source_column"`, `"table_name"`, `"field_name"`); it is the hook
/// an on-call engineer follows back to the failing emission site.
///
/// Panic (rather than `Result`) is appropriate because reaching the
/// helper with a bad identifier indicates either a broken proc-macro
/// emission or a downstream caller deliberately bypassing the macro-
/// support seal — both are framework-bug or misuse cases that the
/// caller cannot recover from.
///
/// Runtime twin of [`const_assert_plain_ident`]; keeping two entry
/// points (rather than having the runtime path just call the const
/// fn) lets the runtime variant emit richer `{value:?}`-interpolated
/// diagnostics that const-panic cannot today. Both paths enforce the
/// identical contract.
#[inline]
pub(crate) fn assert_plain_ident(value: &'static str, role: &'static str) {
    assert!(
        !value.is_empty(),
        "djogi::ident: macro-emitted {role} must not be empty — this is a framework bug"
    );
    assert!(
        value.len() <= MAX_IDENT_LEN,
        "djogi::ident: macro-emitted {role} {value:?} is {len} bytes, exceeding Postgres's \
         {max}-byte usable identifier length (NAMEDATALEN - 1) — either the proc-macro emission \
         is broken or downstream code bypassed the macro-support seal",
        len = value.len(),
        max = MAX_IDENT_LEN,
    );
    let bytes = value.as_bytes();
    assert!(
        bytes[0].is_ascii_alphabetic() || bytes[0] == b'_',
        "djogi::ident: macro-emitted {role} {value:?} must start with a letter or underscore \
         — either the proc-macro emission is broken or downstream code bypassed the \
         macro-support seal"
    );
    for &byte in &bytes[1..] {
        assert!(
            byte.is_ascii_alphanumeric() || byte == b'_',
            "djogi::ident: macro-emitted {role} {value:?} contains a non-identifier character \
             — either the proc-macro emission is broken or downstream code bypassed the \
             macro-support seal"
        );
    }
    // Stack-allocated lowercase for the reserved-keyword lookup. The length
    // check above bounded `value.len()` ≤ 63, so a 64-byte buffer is always
    // sufficient and this path allocates nothing on the heap. Every byte is
    // ASCII alnum or `_` at this point, so `std::str::from_utf8` on the
    // lowercased slice is infallible.
    let mut lower_buf = [0u8; MAX_IDENT_LEN + 1];
    let len = value.len();
    lower_buf[..len].copy_from_slice(bytes);
    lower_buf[..len].make_ascii_lowercase();
    let lower = std::str::from_utf8(&lower_buf[..len])
        .expect("ASCII identifier must be valid UTF-8 after lowercasing");
    assert!(
        RESERVED_KEYWORDS.binary_search(&lower).is_err(),
        "djogi::ident: macro-emitted {role} {value:?} is a reserved Postgres keyword and cannot \
         appear unquoted in generated SQL — either the proc-macro emission is broken or \
         downstream code bypassed the macro-support seal"
    );
}

/// Fallible validator for user-supplied identifiers.
///
/// This is the user-facing twin of [`check_plain_ident`]. It enforces
/// every rule [`check_plain_ident`] enforces (non-empty, ≤ 63 bytes,
/// ASCII letter or underscore first, ASCII alphanumeric or underscore
/// after, optional reserved-keyword block) AND additionally rejects
/// names that start with the framework-reserved [`RESERVED_DJOGI_PREFIX`]
/// (`__djogi_`, ASCII-case-insensitive).
///
/// Use this at every surface where adopters can hand the framework an
/// SQL identifier — window-function aliases, FTS dictionary / source
/// column names, [`crate::query::closure::ClosureModel`] column
/// accessors, outbox table names, runtime enum-type names. Use the
/// plainer [`check_plain_ident`] only for derived names that the
/// framework constructs from already-validated inputs (e.g. the
/// `<table>_outbox` companion table name) where the prefix rule is
/// transitively enforced upstream.
///
/// `check_reserved` toggles the Postgres-keyword block in the same
/// shape as [`check_plain_ident`].
///
/// On failure, the prefix rejection precedes the byte-shape rejections
/// so a name like `__djogi_select` reports the more actionable
/// `ReservedDjogiPrefix` error rather than a Postgres-keyword error
/// that could mislead the caller into renaming the suffix.
pub(crate) fn check_user_supplied_ident(
    value: &str,
    check_reserved: bool,
) -> Result<(), IdentError> {
    if starts_with_reserved_djogi_prefix(value.as_bytes()) {
        return Err(IdentError::ReservedDjogiPrefix);
    }
    check_plain_ident(value, check_reserved)
}

/// Panicking validator for user-supplied identifiers carried as
/// `&'static str` — typically window-function aliases the adopter
/// passes to [`crate::expr::RowNumber::alias`] and friends.
///
/// Combines [`assert_plain_ident`]'s four-rule contract with the
/// framework-reserved-prefix block from
/// [`check_user_supplied_ident`]. Call this at boundaries where the
/// adopter directly hands a static identifier to the SQL emitter and
/// the failure mode is best surfaced as a panic (typed-builder API,
/// where the typed alias is known at compile-time and a typo is the
/// only realistic failure path).
///
/// `role` labels the identifier in the panic message — same convention
/// as [`assert_plain_ident`] — so an on-call engineer can map the
/// panic back to the offending API surface.
#[inline]
pub(crate) fn assert_user_supplied_ident(value: &'static str, role: &'static str) {
    assert!(
        !starts_with_reserved_djogi_prefix(value.as_bytes()),
        "djogi::ident: user-supplied {role} {value:?} is reserved — the `__djogi_` prefix \
         is used for framework-internal identifiers (recursive CTE names like `__djogi_tree`, \
         derived-table aliases like `__djogi_q`, aggregate-tuple slot aliases like \
         `__djogi_agg_N`). Choose a different name."
    );
    assert_plain_ident(value, role);
}

/// Debug-build-only identifier assertion. Expands to
/// [`assert_plain_ident`] under `cfg(debug_assertions)` and to nothing
/// in release builds, so the check runs in tests and `cargo run` but
/// contributes zero overhead to a production `--release` binary.
///
/// Intended for hot paths that accept a `&'static str` from a source
/// the compiler cannot structurally seal — notably the per-row loops
/// in `relation::prefetch` and `relation::select_related` that read
/// `descriptor().fields[].name`. Sealing the `Model` trait stops a
/// hand-rolled `impl Model` from reaching that code, but a hostile
/// `#[derive(Model)]`-equivalent macro in a downstream crate could
/// still feed the framework a malformed field name; the debug assert
/// turns that into a loud framework-bug panic in CI.
macro_rules! debug_assert_ident {
    ($value:expr, $role:literal) => {{
        #[cfg(debug_assertions)]
        {
            $crate::ident::assert_plain_ident($value, $role);
        }
    }};
}

pub(crate) use debug_assert_ident;

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

    fn try_assert(value: &'static str) -> std::thread::Result<()> {
        std::panic::catch_unwind(|| assert_plain_ident(value, "test_ident"))
    }

    #[test]
    fn accepts_plain_identifier() {
        assert!(try_assert("owner_id").is_ok());
    }

    #[test]
    fn accepts_identifier_with_trailing_digits() {
        assert!(try_assert("col1").is_ok());
        assert!(try_assert("t_abc123").is_ok());
    }

    #[test]
    fn accepts_leading_underscore() {
        // Rust convention for private names; Postgres also accepts.
        assert!(try_assert("_internal").is_ok());
        assert!(try_assert("_").is_ok());
    }

    #[test]
    fn rejects_empty() {
        assert!(try_assert("").is_err());
    }

    #[test]
    fn rejects_leading_digit() {
        // Closes the pre-fix hole: the old byte check accepted "123",
        // which would emit `SELECT p.123 ...` or `LEFT JOIN 9table ...`.
        assert!(try_assert("123").is_err());
        assert!(try_assert("9col").is_err());
    }

    #[test]
    fn rejects_reserved_keyword() {
        assert!(try_assert("select").is_err());
        assert!(try_assert("table").is_err());
        assert!(try_assert("where").is_err());
    }

    #[test]
    fn rejects_reserved_keyword_case_insensitively() {
        // Postgres folds unquoted identifiers to lowercase, so
        // "SELECT", "Select", "sElEcT" all reference the SELECT
        // keyword. The validator must match the same case rule.
        assert!(try_assert("SELECT").is_err());
        assert!(try_assert("Where").is_err());
        assert!(try_assert("TaBlE").is_err());
    }

    #[test]
    fn rejects_identifier_exceeding_limit() {
        const LONG: &str = "a234567890123456789012345678901234567890123456789012345678901234";
        assert_eq!(LONG.len(), 64);
        assert!(try_assert(LONG).is_err());
    }

    #[test]
    fn accepts_identifier_at_exactly_63_bytes() {
        const AT_LIMIT: &str = "a23456789012345678901234567890123456789012345678901234567890123";
        assert_eq!(AT_LIMIT.len(), 63);
        assert!(try_assert(AT_LIMIT).is_ok());
    }

    #[test]
    fn rejects_metacharacter_payload() {
        // The original SQL-injection shape from the Task 4 seal fixture.
        assert!(try_assert("owner_id) OR 1=1 --").is_err());
    }

    #[test]
    fn rejects_nul_byte() {
        // Explicit pin for behavior on embedded NUL bytes — these are
        // not alphanumeric or underscore and are rejected on the first
        // per-byte check. Documents the current behavior so a later
        // refactor can't silently regress.
        assert!(try_assert("a\0b").is_err());
    }

    #[test]
    fn rejects_non_ascii_alpha() {
        // Unicode alphabetic chars are not ASCII alphanumeric — wholesale
        // rejected on the per-byte check. Covers the "mixed-script
        // confusable" attack surface.
        assert!(try_assert("café").is_err());
        assert!(try_assert("naïve").is_err());
    }

    #[test]
    fn reserved_keywords_is_sorted_and_lowercase() {
        // binary_search in assert_plain_ident assumes sorted, lowercase
        // entries. Guard against a later edit that adds keywords out of
        // order or in uppercase (which would silently fail to match the
        // lowercased-input lookup).
        for pair in RESERVED_KEYWORDS.windows(2) {
            assert!(
                pair[0] < pair[1],
                "RESERVED_KEYWORDS must be sorted for binary_search: {:?} !< {:?}",
                pair[0],
                pair[1],
            );
        }
        for kw in RESERVED_KEYWORDS {
            assert_eq!(
                kw.to_ascii_lowercase().as_str(),
                *kw,
                "RESERVED_KEYWORDS must be lowercase: {kw:?}"
            );
        }
    }

    #[test]
    fn debug_assert_ident_matches_runtime_validator() {
        // Under `cfg(test)`, `debug_assertions` is on, so the macro must
        // panic identically to `assert_plain_ident`. This pins the macro's
        // behavior against the validator so drift would fail.
        let caught = std::panic::catch_unwind(|| debug_assert_ident!("select", "field_name"));
        assert!(
            caught.is_err(),
            "debug_assert_ident should panic on reserved keyword"
        );
    }

    #[test]
    fn debug_assert_ident_accepts_valid_name() {
        // Paired positive case — confirms the macro is not simply
        // panicking unconditionally and keeps the hot path quiet for
        // well-formed identifiers.
        let ok = std::panic::catch_unwind(|| debug_assert_ident!("owner_id", "field_name"));
        assert!(ok.is_ok());
    }

    // ── Reserved-prefix rule (issues #69, #82) ───────────────────────────────

    #[test]
    fn starts_with_reserved_djogi_prefix_basic_cases() {
        // Const helper used by both `check_user_supplied_ident` and
        // `assert_user_supplied_ident`; pin its behavior on the boundary
        // cases that matter to callers.
        assert!(starts_with_reserved_djogi_prefix(b"__djogi_"));
        assert!(starts_with_reserved_djogi_prefix(b"__djogi_q"));
        assert!(starts_with_reserved_djogi_prefix(b"__djogi_anything"));
        // Postgres folds unquoted identifiers to lowercase, so user
        // spellings with uppercase letters still resolve into the same
        // SQL namespace and must be rejected by the prefix check.
        assert!(starts_with_reserved_djogi_prefix(b"__DJOGI_q"));
        assert!(starts_with_reserved_djogi_prefix(b"__Djogi_q"));
        assert!(!starts_with_reserved_djogi_prefix(b""));
        assert!(!starts_with_reserved_djogi_prefix(b"_"));
        assert!(!starts_with_reserved_djogi_prefix(b"__djogi"));
        assert!(!starts_with_reserved_djogi_prefix(b"__DJOGI"));
        // Single-underscore prefix — used by `notify` channel names like
        // `djogi_<table>` — must NOT collide with the reservation.
        assert!(!starts_with_reserved_djogi_prefix(b"_djogi_"));
        assert!(!starts_with_reserved_djogi_prefix(b"djogi_"));
        // Different prefixes — adopters' own reserved namespaces.
        assert!(!starts_with_reserved_djogi_prefix(b"__myprefix_"));
    }

    #[test]
    fn check_plain_ident_still_accepts_djogi_prefix() {
        // Pin: the general-purpose `check_plain_ident` continues to
        // accept macro-emitted internal identifiers. Adding the
        // reservation rule here would break every framework-internal
        // emission (recursive CTE names, derived-table aliases, slot
        // aliases) — those legitimately live in the namespace.
        assert_eq!(check_plain_ident("__djogi_q", true), Ok(()));
        assert_eq!(check_plain_ident("__djogi_tree", true), Ok(()));
        assert_eq!(check_plain_ident("__djogi_agg_0", true), Ok(()));
    }

    #[test]
    fn check_user_supplied_ident_rejects_djogi_prefix() {
        // The user-facing twin must reject every shape in the reserved
        // namespace, regardless of the suffix. Pin the canonical
        // existing slot names so a later refactor cannot silently let
        // them through.
        assert_eq!(
            check_user_supplied_ident("__djogi_q", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        assert_eq!(
            check_user_supplied_ident("__djogi_tree", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        assert_eq!(
            check_user_supplied_ident("__djogi_agg_0", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        assert_eq!(
            check_user_supplied_ident("__djogi_parent_id", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        assert_eq!(
            check_user_supplied_ident("__DJOGI_q", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        assert_eq!(
            check_user_supplied_ident("__Djogi_q", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        // Surface still rejects post-prefix when applicable: `__djogi_`
        // alone (no suffix) is also reserved — the prefix is the entire
        // exclusion zone.
        assert_eq!(
            check_user_supplied_ident("__djogi_", false),
            Err(IdentError::ReservedDjogiPrefix)
        );
    }

    #[test]
    fn check_user_supplied_ident_prefix_check_precedes_byte_check() {
        // Ordering pin — `__djogi_<reserved keyword>` should report the
        // ReservedDjogiPrefix error rather than Reserved (Postgres
        // keyword), because the reservation rule trips before the byte
        // / keyword scan and gives the caller the more actionable
        // "rename your prefix" diagnostic.
        assert_eq!(
            check_user_supplied_ident("__djogi_select", true),
            Err(IdentError::ReservedDjogiPrefix)
        );
        // Likewise, an over-long name in the namespace prefers the
        // prefix error over `TooLong` because the prefix is the
        // primary failure to fix.
        let overlong = format!("__djogi_{}", "a".repeat(70));
        assert_eq!(
            check_user_supplied_ident(&overlong, false),
            Err(IdentError::ReservedDjogiPrefix)
        );
    }

    #[test]
    fn check_user_supplied_ident_accepts_non_reserved_names() {
        // Positive shapes — anything that the plain validator accepts
        // and that does not enter the reserved namespace must pass
        // unchanged.
        assert_eq!(check_user_supplied_ident("rank", true), Ok(()));
        assert_eq!(check_user_supplied_ident("dense_rank", true), Ok(()));
        assert_eq!(check_user_supplied_ident("_internal", true), Ok(()));
        // Single-underscore + djogi is NOT reserved — the rule is the
        // double-underscore prefix only. This shape mirrors the notify
        // channel names (`djogi_<table>`) the framework derives from
        // already-validated table names.
        assert_eq!(check_user_supplied_ident("djogi_orders", true), Ok(()));
        assert_eq!(check_user_supplied_ident("_djogi_q", true), Ok(()));
    }

    fn try_const_assert_user(value: &'static str) -> std::thread::Result<()> {
        std::panic::catch_unwind(|| const_assert_user_supplied_ident(value, "test_role"))
    }

    #[test]
    fn const_assert_user_supplied_ident_rejects_reserved_prefix() {
        assert!(try_const_assert_user("__djogi_q").is_err());
        assert!(try_const_assert_user("__DJOGI_q").is_err());
        assert!(try_const_assert_user("__Djogi_q").is_err());
        assert!(try_const_assert_user("plain_relation").is_ok());
        assert!(try_const_assert_user("_djogi_relation").is_ok());
    }

    #[test]
    fn check_user_supplied_ident_propagates_other_ident_errors() {
        // The user-supplied helper falls through to `check_plain_ident`
        // for non-prefix-related failures, so the underlying error
        // shape must remain visible to callers (they map it onto their
        // own diagnostic strings).
        assert_eq!(check_user_supplied_ident("", false), Err(IdentError::Empty));
        assert_eq!(
            check_user_supplied_ident("9col", false),
            Err(IdentError::BadFirst { byte: b'9' })
        );
        assert_eq!(
            check_user_supplied_ident("select", true),
            Err(IdentError::Reserved)
        );
    }

    fn try_assert_user(value: &'static str) -> std::thread::Result<()> {
        std::panic::catch_unwind(|| assert_user_supplied_ident(value, "test_role"))
    }

    #[test]
    fn assert_user_supplied_ident_panics_on_reserved_prefix() {
        // The panicking twin is the surface window-fn `.alias(...)`
        // routes through; a typo collision with `__djogi_*` must
        // produce a loud framework-bug-style panic so the adopter
        // catches it in their unit tests rather than at SQL emission.
        let caught = try_assert_user("__djogi_q");
        assert!(
            caught.is_err(),
            "assert_user_supplied_ident must panic on reserved prefix"
        );
        let caught = try_assert_user("__djogi_anything");
        assert!(caught.is_err());
    }

    #[test]
    fn assert_user_supplied_ident_accepts_valid_names() {
        assert!(try_assert_user("rank").is_ok());
        assert!(try_assert_user("dense_rank").is_ok());
        assert!(try_assert_user("_internal_alias").is_ok());
        assert!(try_assert_user("djogi_pluralized_rank").is_ok());
    }

    #[test]
    fn assert_user_supplied_ident_panics_on_other_violations() {
        // Falls through to `assert_plain_ident` for byte-shape and
        // reserved-keyword violations — the panic just routes through
        // a different message body.
        assert!(try_assert_user("").is_err());
        assert!(try_assert_user("9col").is_err());
        assert!(try_assert_user("select").is_err());
    }

    #[test]
    fn reserved_djogi_prefix_constant_is_stable() {
        // The exact byte sequence `__djogi_` is documented in
        // `docs/spec/reserved-identifiers.md` and is part of djogi's
        // public stability contract. Pin it so a refactor that, say,
        // capitalizes the bytes or swaps in a different separator
        // immediately surfaces.
        assert_eq!(RESERVED_DJOGI_PREFIX, b"__djogi_");
        assert_eq!(RESERVED_DJOGI_PREFIX.len(), 8);
    }
}