use crate::ddl_ast::parse::clause::{ClauseCursor, Tok, Token};
use crate::error::SqlError;
use crate::reserved::check_identifier;
const PAREN_OWNING_CLAUSE_KEYWORDS: [&str; 5] = ["WITH", "BALANCED", "ON", "PARTITION", "USING"];
const OPTION_KEYS: [&str; 11] = [
"ENGINE",
"PROFILE",
"VECTOR_FIELD",
"PARTITION_BY",
"DIM",
"METRIC",
"PAYLOAD_INDEXES",
"APPEND_ONLY",
"HASH_CHAIN",
"BITEMPORAL",
"SIGNED_DELTAS",
];
pub(super) fn matching_close(
toks: &[Token<'_>],
open_idx: usize,
open: char,
close: char,
) -> Option<usize> {
let mut depth = 0usize;
for (i, tok) in toks.iter().enumerate().skip(open_idx) {
match tok.tok {
Tok::Punct(p) if p == open => depth += 1,
Tok::Punct(p) if p == close => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
pub(super) fn read_column_list(
c: &mut ClauseCursor<'_, '_>,
src: &str,
) -> Result<Vec<(String, String)>, SqlError> {
let toks = c.remaining_tokens();
let Some(open) = toks.iter().position(|t| t.tok == Tok::Punct('(')) else {
return Ok(Vec::new());
};
let owned_by_clause = toks[..open].iter().any(|t| {
matches!(t.tok, Tok::Word(w) if PAREN_OWNING_CLAUSE_KEYWORDS
.iter()
.any(|kw| w.eq_ignore_ascii_case(kw)))
});
if owned_by_clause {
return Ok(Vec::new());
}
let is_options = matches!(
toks.get(open + 1).map(|t| &t.tok),
Some(Tok::Word(w)) if OPTION_KEYS.iter().any(|k| w.eq_ignore_ascii_case(k))
);
if is_options {
return Ok(Vec::new());
}
let close =
matching_close(toks, open, '(', ')').ok_or_else(|| c.error("column list: unclosed '('"))?;
let inner = &toks[open + 1..close];
c.skip(close + 1);
let mut pairs = Vec::new();
let mut depth = 0i32;
let mut start = 0usize;
for (i, tok) in inner.iter().enumerate() {
match tok.tok {
Tok::Punct('(') => depth += 1,
Tok::Punct(')') => depth -= 1,
Tok::Punct(',') if depth == 0 => {
if let Some(pair) = parse_column(src, &inner[start..i])? {
pairs.push(pair);
}
start = i + 1;
}
_ => {}
}
}
if let Some(pair) = parse_column(src, &inner[start..])? {
pairs.push(pair);
}
Ok(pairs)
}
fn joined_text(src: &str, toks: &[Token<'_>]) -> String {
let mut out = String::new();
let mut prev_end: Option<usize> = None;
for tok in toks {
if let Some(end) = prev_end
&& end < tok.span.start
{
out.push(' ');
}
out.push_str(&src[tok.span.clone()]);
prev_end = Some(tok.span.end);
}
out
}
fn unsupported(kind: &str) -> SqlError {
let (feature, hint) = match kind {
"PRIMARY" => (
"PRIMARY KEY",
"use the inline form on the column instead: `<colname> <TYPE> PRIMARY KEY`",
),
"UNIQUE" => (
"UNIQUE constraint",
"use a UNIQUE secondary index: CREATE INDEX ... ON collection (field) UNIQUE",
),
"CHECK" => (
"CHECK constraint",
"CHECK constraints are unsupported; enforce in application code \
or use a typed function in INSERT",
),
"FOREIGN" | "REFERENCES" => (
if kind == "FOREIGN" {
"FOREIGN KEY constraint"
} else {
"REFERENCES constraint"
},
"FOREIGN KEY enforcement is unsupported; enforce in application code",
),
_ => (
"CONSTRAINT clause",
"named constraints are unsupported; \
use NodeDB-native enforcement (indexes, typeguards)",
),
};
SqlError::UnsupportedConstraint {
feature: feature.to_string(),
hint: hint.to_string(),
}
}
fn named_constraint(kind: &str) -> SqlError {
let (feature, hint) = match kind {
"PRIMARY" => (
"CONSTRAINT ... PRIMARY KEY".to_string(),
"use the inline form on the column instead: `<colname> <TYPE> PRIMARY KEY`",
),
"UNIQUE" => (
"CONSTRAINT ... UNIQUE".to_string(),
"use a UNIQUE secondary index: CREATE INDEX ... ON collection (field) UNIQUE",
),
"CHECK" => (
"CONSTRAINT ... CHECK".to_string(),
"CHECK constraints are unsupported; enforce in application code \
or use a typed function in INSERT",
),
"FOREIGN" => (
"CONSTRAINT ... FOREIGN KEY".to_string(),
"FOREIGN KEY enforcement is unsupported; enforce in application code",
),
_ => (
format!("CONSTRAINT {kind}"),
"named constraints are unsupported; \
use NodeDB-native enforcement (indexes, typeguards)",
),
};
SqlError::UnsupportedConstraint {
feature,
hint: hint.to_string(),
}
}
fn parse_column(src: &str, toks: &[Token<'_>]) -> Result<Option<(String, String)>, SqlError> {
let Some(first) = toks.first() else {
return Ok(None);
};
let name = match &first.tok {
Tok::Word(w) => {
let upper = w.to_ascii_uppercase();
match upper.as_str() {
"PRIMARY" | "UNIQUE" | "CHECK" | "FOREIGN" | "REFERENCES" => {
return Err(unsupported(&upper));
}
"CONSTRAINT" => {
let kind = match toks.get(2).map(|t| &t.tok) {
Some(Tok::Word(k)) => k.to_ascii_uppercase(),
_ => String::new(),
};
return Err(named_constraint(&kind));
}
_ => check_identifier(w)?,
}
}
Tok::Ident(_) => check_identifier(&src[first.span.clone()])?,
_ => {
return Err(SqlError::Parse {
detail: format!(
"column definition must start with a column name, found '{}'",
&src[first.span.clone()]
),
});
}
};
let rest = &toks[1..];
let mut depth = 0i32;
for tok in rest {
match tok.tok {
Tok::Punct('(') => depth += 1,
Tok::Punct(')') => depth -= 1,
Tok::Word(w) if depth == 0 => {
let upper = w.to_ascii_uppercase();
match upper.as_str() {
"GENERATED" => break,
"UNIQUE" | "CHECK" | "FOREIGN" | "REFERENCES" | "CONSTRAINT" => {
return Err(unsupported(&upper));
}
_ => {}
}
}
_ => {}
}
}
if rest.is_empty() {
return Ok(None);
}
Ok(Some((name, joined_text(src, rest))))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ddl_ast::parse::clause::tokenize;
fn columns(body: &str) -> Result<Vec<(String, String)>, SqlError> {
let toks = tokenize(body);
let mut c = ClauseCursor::new(body, &toks, "CREATE COLLECTION");
read_column_list(&mut c, body)
}
#[test]
fn with_clause_without_spaces_is_not_a_column_list() {
assert!(
columns("WITH (engine='document_schemaless')")
.expect("WITH options")
.is_empty()
);
}
#[test]
fn balanced_clause_is_not_a_column_list() {
let cols = columns(
"WITH BALANCED ON (group_key = journal_id, debit = 'DEBIT', \
credit = 'CREDIT', amount = amount)",
)
.expect("BALANCED clause");
assert!(cols.is_empty(), "got {cols:?}");
}
#[test]
fn with_clause_with_unknown_key_is_not_a_column_list() {
assert!(columns("WITH (ttl = 60)").expect("WITH options").is_empty());
}
#[test]
fn column_list_following_the_name_is_parsed() {
let cols = columns("(id TEXT, amount NUMERIC) WITH BALANCED ON (a = b)").expect("list");
assert_eq!(cols.len(), 2);
assert_eq!(cols[0].0, "id");
assert_eq!(cols[1].0, "amount");
}
#[test]
fn type_declaration_before_the_column_list_is_parsed() {
for body in [
"TYPE DOCUMENT (id STRING, name STRING, score INT)",
"TYPE document (id STRING, name STRING, score INT)",
"TYPE DOCUMENT STRICT (id STRING, name STRING, score INT)",
] {
let cols = columns(body).expect("column list");
assert_eq!(
cols.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
["id", "name", "score"],
"body {body} must declare its three columns"
);
}
}
#[test]
fn keyword_introduced_column_lists_are_parsed() {
for body in [
"COLUMNS (id TEXT, v FLOAT) WITH (engine='columnar')",
"FIELDS (id TEXT, v FLOAT)",
"STRICT (id TEXT, v FLOAT)",
] {
assert_eq!(columns(body).expect("column list").len(), 2, "{body}");
}
}
#[test]
fn generated_clause_after_unicode_type_preserves_original_offsets() {
let cols = columns("(slug CUSTOMffff GENERATED ALWAYS AS (lower(name)))").expect("list");
assert_eq!(
cols,
vec![(
"slug".to_string(),
"CUSTOMffff GENERATED ALWAYS AS (lower(name))".to_string()
)]
);
}
#[test]
fn literal_punctuation_does_not_split_or_end_the_list() {
let cols = columns("(note TEXT DEFAULT 'a, b)', id INT)").expect("list");
assert_eq!(
cols,
vec![
("note".to_string(), "TEXT DEFAULT 'a, b)'".to_string()),
("id".to_string(), "INT".to_string()),
]
);
}
#[test]
fn constraint_word_inside_a_default_literal_is_not_a_constraint() {
let cols = columns("(note TEXT DEFAULT 'CHECK this')").expect("list");
assert_eq!(cols[0].1, "TEXT DEFAULT 'CHECK this'");
}
#[test]
fn inline_and_table_constraints_are_typed_errors() {
assert!(matches!(
columns("(id INT UNIQUE)"),
Err(SqlError::UnsupportedConstraint { .. })
));
assert!(matches!(
columns("(id INT, PRIMARY KEY (id))"),
Err(SqlError::UnsupportedConstraint { .. })
));
assert!(matches!(
columns("(id INT, CONSTRAINT pk CHECK (id > 0))"),
Err(SqlError::UnsupportedConstraint { feature, .. }) if feature == "CONSTRAINT ... CHECK"
));
}
#[test]
fn unclosed_column_list_is_an_error() {
assert!(matches!(columns("(id INT"), Err(SqlError::Parse { .. })));
}
}