#[cfg(test)]
mod tests;
use std::fmt::Write as _;
use super::compiled::{CompiledSchema, SubscribableEntity};
const PK_COLUMN: &str = "id";
const TENANT_COLUMN: &str = "tenant_id";
const PG_IDENT_MAX: usize = 63;
#[must_use]
pub fn generate_capture_trigger_ddl(schema: &CompiledSchema) -> String {
if schema.subscribable.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(
"-- FraiseQL external-write capture triggers (#366) — generated.\n\
-- Requires core.tb_entity_change_log + core.fn_entity_change_log_capture().\n\n",
);
for entity in &schema.subscribable {
out.push_str(&entity_ddl(entity));
}
out
}
fn entity_ddl(entity: &SubscribableEntity) -> String {
let mut out = String::new();
for table in &entity.tables {
match split_qualified_ident(table) {
Some((schema_part, table_part)) => {
out.push_str(&table_ddl(
&entity.entity_type,
schema_part.as_deref(),
&table_part,
entity.pre_image,
));
},
None => {
let _ = write!(
out,
"-- WARNING: skipped @subscribable table {table:?} on type {:?} \
(not a plain or schema-qualified identifier)\n\n",
entity.entity_type
);
},
}
}
out
}
fn table_ddl(
entity_type: &str,
schema_part: Option<&str>,
table_part: &str,
pre_image: bool,
) -> String {
let target = match schema_part {
Some(s) => format!("\"{s}\".\"{table_part}\""),
None => format!("\"{table_part}\""),
};
let args = if pre_image {
format!("{}, '{PK_COLUMN}', '{TENANT_COLUMN}', 'true'", sql_string_literal(entity_type))
} else {
format!("{}, '{PK_COLUMN}', '{TENANT_COLUMN}'", sql_string_literal(entity_type))
};
let mut out = String::new();
for (suffix, when, referencing) in [
("ins", "INSERT", "NEW TABLE AS new_table"),
("upd", "UPDATE", "OLD TABLE AS old_table NEW TABLE AS new_table"),
("del", "DELETE", "OLD TABLE AS old_table"),
] {
let trigger = trigger_name(suffix, schema_part, table_part);
let _ = write!(
out,
"DROP TRIGGER IF EXISTS \"{trigger}\" ON {target};\n\
CREATE TRIGGER \"{trigger}\" AFTER {when} ON {target}\n \
REFERENCING {referencing} FOR EACH STATEMENT\n \
EXECUTE FUNCTION core.fn_entity_change_log_capture({args});\n\n"
);
}
out
}
fn trigger_name(suffix: &str, schema_part: Option<&str>, table_part: &str) -> String {
let prefix = format!("tr_cdc_capture_{suffix}_");
let sanitized = sanitize_ident(table_part);
let budget = PG_IDENT_MAX - prefix.len();
if sanitized.len() <= budget {
return format!("{prefix}{sanitized}");
}
let full = match schema_part {
Some(s) => format!("{s}.{table_part}"),
None => table_part.to_string(),
};
let hash = format!("{:08x}", djb2(&full));
let keep = budget.saturating_sub(hash.len() + 1);
format!("{prefix}{}_{hash}", &sanitized[..keep])
}
fn sanitize_ident(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn split_qualified_ident(table: &str) -> Option<(Option<String>, String)> {
let parts: Vec<&str> = table.split('.').collect();
match parts.as_slice() {
[t] if is_plain_ident(t) => Some((None, (*t).to_string())),
[s, t] if is_plain_ident(s) && is_plain_ident(t) => {
Some((Some((*s).to_string()), (*t).to_string()))
},
_ => None,
}
}
fn is_plain_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {},
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn sql_string_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn djb2(s: &str) -> u32 {
s.bytes().fold(5381u32, |h, b| h.wrapping_mul(33).wrapping_add(u32::from(b)))
}