pub(crate) const MAX_IDENT_LEN: usize = 63;
pub(crate) const RESERVED_DJOGI_PREFIX: &[u8] = b"__djogi_";
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
}
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",
];
pub(crate) const fn const_assert_plain_ident(value: &'static str, role: &'static str) {
let bytes = value.as_bytes();
assert!(
!bytes.is_empty(),
"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"
);
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;
}
let mut k = 0;
while k < RESERVED_KEYWORDS.len() {
let kw = RESERVED_KEYWORDS[k].as_bytes();
if const_eq_ignore_ascii_case(bytes, kw) {
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;
}
let _ = role;
}
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 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() {
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdentError {
Empty,
TooLong { len: usize },
BadFirst { byte: u8 },
BadByte { idx: usize, byte: u8 },
Reserved,
ReservedDjogiPrefix,
}
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 {
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(())
}
#[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"
);
}
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"
);
}
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)
}
#[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);
}
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() {
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() {
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() {
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() {
assert!(try_assert("owner_id) OR 1=1 --").is_err());
}
#[test]
fn rejects_nul_byte() {
assert!(try_assert("a\0b").is_err());
}
#[test]
fn rejects_non_ascii_alpha() {
assert!(try_assert("café").is_err());
assert!(try_assert("naïve").is_err());
}
#[test]
fn reserved_keywords_is_sorted_and_lowercase() {
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() {
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() {
let ok = std::panic::catch_unwind(|| debug_assert_ident!("owner_id", "field_name"));
assert!(ok.is_ok());
}
#[test]
fn starts_with_reserved_djogi_prefix_basic_cases() {
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"));
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"));
assert!(!starts_with_reserved_djogi_prefix(b"_djogi_"));
assert!(!starts_with_reserved_djogi_prefix(b"djogi_"));
assert!(!starts_with_reserved_djogi_prefix(b"__myprefix_"));
}
#[test]
fn check_plain_ident_still_accepts_djogi_prefix() {
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() {
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)
);
assert_eq!(
check_user_supplied_ident("__djogi_", false),
Err(IdentError::ReservedDjogiPrefix)
);
}
#[test]
fn check_user_supplied_ident_prefix_check_precedes_byte_check() {
assert_eq!(
check_user_supplied_ident("__djogi_select", true),
Err(IdentError::ReservedDjogiPrefix)
);
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() {
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(()));
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() {
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() {
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() {
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() {
assert_eq!(RESERVED_DJOGI_PREFIX, b"__djogi_");
assert_eq!(RESERVED_DJOGI_PREFIX.len(), 8);
}
}