use dibs_sql::{check_constraint_name, index_name, trigger_check_name, unique_index_name};
use facet::{Facet, Shape, Type, UserType};
use indexmap::IndexMap;
use std::fmt;
facet::define_attr_grammar! {
ns "dibs";
crate_path ::dibs;
pub enum Attr {
Table(&'static str),
Pk,
Unique,
Fk(&'static str),
NotNull,
Default(&'static str),
Column(&'static str),
Index(Option<&'static str>),
CompositeIndex(CompositeIndex),
CompositeUnique(CompositeUnique),
Check(Check),
TriggerCheck(TriggerCheck),
Auto,
Long,
Label,
Lang(&'static str),
Icon(&'static str),
Subtype(&'static str),
}
pub struct CompositeIndex {
pub name: Option<&'static str>,
pub columns: &'static str,
pub filter: Option<&'static str>,
}
pub struct CompositeUnique {
pub name: Option<&'static str>,
pub columns: &'static str,
pub filter: Option<&'static str>,
}
pub struct Check {
pub name: Option<&'static str>,
pub expr: &'static str,
}
pub struct TriggerCheck {
pub name: Option<&'static str>,
pub expr: &'static str,
pub message: Option<&'static str>,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PgType {
SmallInt,
Integer,
BigInt,
Real,
DoublePrecision,
Numeric,
Boolean,
Text,
Bytea,
Timestamptz,
Date,
Time,
Uuid,
Jsonb,
TextArray,
BigIntArray,
IntegerArray,
}
impl PgType {
pub fn to_rust_type(&self) -> &'static str {
match self {
PgType::SmallInt => "i16",
PgType::Integer => "i32",
PgType::BigInt => "i64",
PgType::Real => "f32",
PgType::DoublePrecision => "f64",
PgType::Numeric => "Decimal",
PgType::Boolean => "bool",
PgType::Text => "String",
PgType::Bytea => "Vec<u8>",
PgType::Timestamptz => "Timestamp",
PgType::Date => "Date",
PgType::Time => "Time",
PgType::Uuid => "Uuid",
PgType::Jsonb => "Jsonb<facet_value::Value>",
PgType::TextArray => "Vec<String>",
PgType::BigIntArray => "Vec<i64>",
PgType::IntegerArray => "Vec<i32>",
}
}
pub fn is_integer(&self) -> bool {
matches!(self, PgType::SmallInt | PgType::Integer | PgType::BigInt)
}
}
impl fmt::Display for PgType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PgType::SmallInt => write!(f, "SMALLINT"),
PgType::Integer => write!(f, "INTEGER"),
PgType::BigInt => write!(f, "BIGINT"),
PgType::Real => write!(f, "REAL"),
PgType::DoublePrecision => write!(f, "DOUBLE PRECISION"),
PgType::Numeric => write!(f, "NUMERIC"),
PgType::Boolean => write!(f, "BOOLEAN"),
PgType::Text => write!(f, "TEXT"),
PgType::Bytea => write!(f, "BYTEA"),
PgType::Timestamptz => write!(f, "TIMESTAMPTZ"),
PgType::Date => write!(f, "DATE"),
PgType::Time => write!(f, "TIME"),
PgType::Uuid => write!(f, "UUID"),
PgType::Jsonb => write!(f, "JSONB"),
PgType::TextArray => write!(f, "TEXT[]"),
PgType::BigIntArray => write!(f, "BIGINT[]"),
PgType::IntegerArray => write!(f, "INTEGER[]"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Column {
pub name: String,
pub pg_type: PgType,
pub rust_type: Option<String>,
pub nullable: bool,
pub default: Option<String>,
pub primary_key: bool,
pub unique: bool,
pub auto_generated: bool,
pub long: bool,
pub label: bool,
pub enum_variants: Vec<String>,
pub doc: Option<String>,
pub lang: Option<String>,
pub icon: Option<String>,
pub subtype: Option<String>,
}
impl Column {
pub fn is_identity(&self) -> bool {
self.auto_generated && self.default.is_none() && self.pg_type.is_integer()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ForeignKey {
pub columns: Vec<String>,
pub references_table: String,
pub references_columns: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Asc,
Desc,
}
impl SortOrder {
pub fn to_sql(&self) -> &'static str {
match self {
SortOrder::Asc => "",
SortOrder::Desc => " DESC",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NullsOrder {
#[default]
Default,
First,
Last,
}
impl NullsOrder {
pub fn to_sql(&self) -> &'static str {
match self {
NullsOrder::Default => "",
NullsOrder::First => " NULLS FIRST",
NullsOrder::Last => " NULLS LAST",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexColumn {
pub name: String,
pub order: SortOrder,
pub nulls: NullsOrder,
}
impl IndexColumn {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
order: SortOrder::Asc,
nulls: NullsOrder::Default,
}
}
pub fn desc(name: impl Into<String>) -> Self {
Self {
name: name.into(),
order: SortOrder::Desc,
nulls: NullsOrder::Default,
}
}
pub fn nulls_first(name: impl Into<String>) -> Self {
Self {
name: name.into(),
order: SortOrder::Asc,
nulls: NullsOrder::First,
}
}
pub fn to_sql(&self, quote_ident: impl Fn(&str) -> String) -> String {
format!(
"{}{}{}",
quote_ident(&self.name),
self.order.to_sql(),
self.nulls.to_sql()
)
}
pub fn parse(spec: &str) -> Self {
let spec = spec.trim();
let upper = spec.to_uppercase();
let (spec_without_nulls, nulls) = if upper.ends_with(" NULLS FIRST") {
(&spec[..spec.len() - 12], NullsOrder::First)
} else if upper.ends_with(" NULLS LAST") {
(&spec[..spec.len() - 11], NullsOrder::Last)
} else {
(spec, NullsOrder::Default)
};
let trimmed = spec_without_nulls.trim();
let upper_trimmed = trimmed.to_uppercase();
let (name, order) = if upper_trimmed.ends_with(" DESC") {
(
trimmed[..trimmed.len() - 5].trim().to_string(),
SortOrder::Desc,
)
} else if upper_trimmed.ends_with(" ASC") {
(
trimmed[..trimmed.len() - 4].trim().to_string(),
SortOrder::Asc,
)
} else {
(trimmed.to_string(), SortOrder::Asc)
};
fn unquote_pg_ident_if_quoted(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
let inner = &s[1..s.len() - 1];
return inner.replace("\"\"", "\"");
}
s.to_string()
}
Self {
name: unquote_pg_ident_if_quoted(&name),
order,
nulls,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Index {
pub name: String,
pub columns: Vec<IndexColumn>,
pub unique: bool,
pub where_clause: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SourceLocation {
pub file: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}
impl SourceLocation {
pub fn is_known(&self) -> bool {
self.file.is_some()
}
pub fn to_string_short(&self) -> Option<String> {
let file = self.file.as_ref()?;
match (self.line, self.column) {
(Some(line), Some(col)) => Some(format!("{}:{}:{}", file, line, col)),
(Some(line), None) => Some(format!("{}:{}", file, line)),
_ => Some(file.clone()),
}
}
}
impl fmt::Display for SourceLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.to_string_short() {
Some(s) => write!(f, "{}", s),
None => write!(f, "<unknown>"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CheckConstraint {
pub name: String,
pub expr: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TriggerCheckConstraint {
pub name: String,
pub expr: String,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Table {
pub name: String,
pub columns: Vec<Column>,
pub check_constraints: Vec<CheckConstraint>,
pub trigger_checks: Vec<TriggerCheckConstraint>,
pub foreign_keys: Vec<ForeignKey>,
pub indices: Vec<Index>,
pub source: SourceLocation,
pub doc: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Schema {
pub tables: IndexMap<String, Table>,
}
impl Schema {
pub fn new() -> Self {
Self::default()
}
pub fn get_table(&self, name: &str) -> Option<&Table> {
self.tables.get(name)
}
pub fn iter_tables(&self) -> impl Iterator<Item = &Table> {
self.tables.values()
}
}
pub struct TableDef {
pub shape: &'static Shape,
}
impl TableDef {
pub const fn new<T: Facet<'static>>() -> Self {
Self { shape: T::SHAPE }
}
pub fn table_name(&self) -> Option<&'static str> {
shape_get_dibs_attr_str(self.shape, "table")
}
pub fn to_table(&self) -> Option<Table> {
let table_name = self.table_name()?.to_string();
let struct_type = match &self.shape.ty {
Type::User(UserType::Struct(s)) => s,
_ => return None,
};
let mut columns = Vec::new();
let mut check_constraints = Vec::new();
let mut trigger_checks = Vec::new();
let mut foreign_keys = Vec::new();
let mut indices = Vec::new();
for attr in self.shape.attributes.iter() {
if attr.ns == Some("dibs")
&& attr.key == "composite_index"
&& let Some(Attr::CompositeIndex(composite)) = attr.get_as::<Attr>()
{
let cols: Vec<IndexColumn> = composite
.columns
.split(',')
.map(IndexColumn::parse)
.collect();
let col_names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
let idx_name = composite
.name
.map(|s| s.to_string())
.unwrap_or_else(|| index_name(&table_name, &col_names));
indices.push(Index {
name: idx_name,
columns: cols,
unique: false,
where_clause: composite.filter.map(|s| s.to_string()),
});
}
if attr.ns == Some("dibs")
&& attr.key == "composite_unique"
&& let Some(Attr::CompositeUnique(composite)) = attr.get_as::<Attr>()
{
let cols: Vec<IndexColumn> = composite
.columns
.split(',')
.map(IndexColumn::parse)
.collect();
let col_names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
let idx_name = composite
.name
.map(|s| s.to_string())
.unwrap_or_else(|| unique_index_name(&table_name, &col_names));
indices.push(Index {
name: idx_name,
columns: cols,
unique: true,
where_clause: composite.filter.map(|s| s.to_string()),
});
}
if attr.ns == Some("dibs")
&& attr.key == "check"
&& let Some(Attr::Check(check)) = attr.get_as::<Attr>()
{
let expr = unescape_rust_string_escapes(check.expr);
let name = check
.name
.map(|s| s.to_string())
.unwrap_or_else(|| check_constraint_name(&table_name, &expr));
check_constraints.push(CheckConstraint { name, expr });
}
if attr.ns == Some("dibs")
&& attr.key == "trigger_check"
&& let Some(Attr::TriggerCheck(trig)) = attr.get_as::<Attr>()
{
let expr = unescape_rust_string_escapes(trig.expr);
let name = trig
.name
.map(|s| s.to_string())
.unwrap_or_else(|| trigger_check_name(&table_name, &expr));
trigger_checks.push(TriggerCheckConstraint {
name,
expr,
message: trig.message.map(unescape_rust_string_escapes),
});
}
}
for field in struct_type.fields {
let field_shape = field.shape.get();
let col_name = field_get_dibs_attr_str(field, "column")
.map(|s| s.to_string())
.unwrap_or_else(|| field.name.to_string());
let (inner_shape, nullable) = unwrap_option(field_shape);
let pg_type = match shape_to_pg_type(inner_shape) {
Some(pg_type) => pg_type,
None => {
eprintln!(
"dibs: unsupported type '{}' for column '{}' in table '{}' ({})",
inner_shape,
field.name,
table_name,
self.shape.source_file.unwrap_or("<unknown>")
);
return None;
}
};
let primary_key = field_has_dibs_attr(field, "pk");
let unique = field_has_dibs_attr(field, "unique");
let default = field_get_dibs_attr_str(field, "default").map(|s| s.to_string());
let doc = if field.doc.is_empty() {
None
} else {
Some(field.doc.join("\n"))
};
let auto_generated =
is_auto_generated_default(&default) || field_has_dibs_attr(field, "auto");
let lang = field_get_dibs_attr_str(field, "lang").map(|s| s.to_string());
let long = field_has_dibs_attr(field, "long") || lang.is_some();
let label = field_has_dibs_attr(field, "label");
let subtype = field_get_dibs_attr_str(field, "subtype").map(|s| s.to_string());
let explicit_icon = field_get_dibs_attr_str(field, "icon").map(|s| s.to_string());
let icon = explicit_icon.or_else(|| {
subtype
.as_ref()
.and_then(|st| subtype_default_icon(st).map(|s| s.to_string()))
});
let enum_variants = extract_enum_variants(inner_shape);
let rust_type = pg_type.to_rust_type().to_string();
columns.push(Column {
name: col_name.clone(),
pg_type,
rust_type: Some(rust_type),
nullable,
default,
primary_key,
unique,
auto_generated,
long,
label,
enum_variants,
doc,
lang,
icon,
subtype,
});
if let Some(fk_ref) = field_get_dibs_attr_str(field, "fk") {
let parsed = parse_fk_reference(fk_ref);
match parsed {
Some((ref_table, ref_col)) => {
foreign_keys.push(ForeignKey {
columns: vec![field.name.to_string()],
references_table: ref_table.to_string(),
references_columns: vec![ref_col.to_string()],
});
}
None => {
eprintln!(
"dibs: invalid FK format '{}' for field '{}' in table '{}' - expected 'table.column' or 'table(column)' ({})",
fk_ref,
field.name,
table_name,
self.shape.source_file.unwrap_or("<unknown>")
);
}
}
}
if field_has_dibs_attr(field, "index") {
let idx_name = field_get_dibs_attr_str(field, "index")
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| crate::index_name(&table_name, &[&col_name]));
indices.push(Index {
name: idx_name,
columns: vec![IndexColumn::new(col_name.clone())],
unique: false,
where_clause: None, });
}
}
let source = SourceLocation {
file: self.shape.source_file.map(|s| s.to_string()),
line: self.shape.source_line,
column: self.shape.source_column,
};
let doc = if self.shape.doc.is_empty() {
None
} else {
Some(self.shape.doc.join("\n"))
};
let icon = shape_get_dibs_attr_str(self.shape, "icon").map(|s| s.to_string());
Some(Table {
name: table_name,
columns,
check_constraints,
trigger_checks,
foreign_keys,
indices,
source,
doc,
icon,
})
}
}
fn unwrap_option(lhs: &'static Shape) -> (&'static Shape, bool) {
let rhs = Option::<()>::SHAPE;
if lhs.decl_id == rhs.decl_id {
if let Some(inner) = lhs.inner {
return (inner, true);
}
}
(lhs, false)
}
#[test]
fn test_unwrap_option() {
let (inner, success) = unwrap_option(Option::<dibs_jsonb::Jsonb<facet_value::Value>>::SHAPE);
assert!(success);
assert_eq!(inner, dibs_jsonb::Jsonb::<facet_value::Value>::SHAPE);
}
fn subtype_default_icon(subtype: &str) -> Option<&'static str> {
match subtype {
"email" => Some("mail"),
"phone" => Some("phone"),
"url" | "website" => Some("link"),
"username" => Some("at-sign"),
"image" | "avatar" | "photo" => Some("image"),
"file" => Some("file"),
"video" => Some("video"),
"audio" => Some("music"),
"currency" | "money" | "price" => Some("coins"),
"percent" | "percentage" => Some("percent"),
"password" => Some("lock"),
"secret" | "token" | "api_key" => Some("key"),
"code" => Some("code"),
"json" => Some("braces"),
"markdown" | "md" => Some("file-text"),
"html" => Some("code"),
"regex" => Some("asterisk"),
"address" => Some("map-pin"),
"city" => Some("building-2"),
"country" => Some("flag"),
"zip" | "postal_code" => Some("hash"),
"ip" | "ip_address" => Some("globe"),
"coordinates" | "geo" => Some("map"),
"slug" => Some("link-2"),
"color" | "hex_color" => Some("palette"),
"tag" | "tags" => Some("tag"),
"uuid" => Some("fingerprint"),
"sku" | "barcode" => Some("scan-barcode"),
"version" => Some("git-branch"),
"duration" => Some("timer"),
_ => None,
}
}
fn shape_get_dibs_attr_str(shape: &Shape, key: &str) -> Option<&'static str> {
shape.attributes.iter().find_map(|attr| {
if attr.ns == Some("dibs") && attr.key == key {
attr.get_as::<&str>().copied()
} else {
None
}
})
}
fn field_has_dibs_attr(field: &facet::Field, key: &str) -> bool {
field
.attributes
.iter()
.any(|attr| attr.ns == Some("dibs") && attr.key == key)
}
fn field_get_dibs_attr_str(field: &facet::Field, key: &str) -> Option<&'static str> {
field.attributes.iter().find_map(|attr| {
if attr.ns == Some("dibs") && attr.key == key {
attr.get_as::<&str>().copied()
} else {
None
}
})
}
fn is_auto_generated_default(default: &Option<String>) -> bool {
let Some(def) = default else {
return false;
};
let lower = def.to_lowercase();
if lower.contains("nextval(") {
return true;
}
if lower.contains("gen_random_uuid()") || lower.contains("uuid_generate_v") {
return true;
}
if lower.contains("now()") || lower.contains("current_timestamp") {
return true;
}
false
}
fn extract_enum_variants(shape: &'static Shape) -> Vec<String> {
if let Type::User(UserType::Enum(enum_type)) = shape.ty {
enum_type
.variants
.iter()
.map(|v| v.name.to_string())
.collect()
} else {
vec![]
}
}
fn unescape_rust_string_escapes(value: &str) -> String {
if !value.contains('\\') {
return value.to_string();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('\'') => out.push('\''),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('0') => out.push('\0'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
pub fn parse_fk_reference(fk_ref: &str) -> Option<(&str, &str)> {
if let Some((table, col)) = fk_ref.split_once('.')
&& !table.is_empty()
&& !col.is_empty()
{
return Some((table, col));
}
if let Some(paren_idx) = fk_ref.find('(')
&& fk_ref.ends_with(')')
{
let table = &fk_ref[..paren_idx];
let col = &fk_ref[paren_idx + 1..fk_ref.len() - 1];
if !table.is_empty() && !col.is_empty() {
return Some((table, col));
}
}
None
}
pub fn shape_to_pg_type(shape: &Shape) -> Option<PgType> {
if shape.decl_id == dibs_jsonb::Jsonb::<()>::SHAPE.decl_id {
return Some(PgType::Jsonb);
}
if matches!(&shape.def, facet::Def::List(_)) {
if let Some(inner) = shape.inner {
if inner == u8::SHAPE {
return Some(PgType::Bytea);
} else if inner == String::SHAPE {
return Some(PgType::TextArray);
} else if inner == i64::SHAPE {
return Some(PgType::BigIntArray);
} else if inner == i32::SHAPE {
return Some(PgType::IntegerArray);
}
}
return None;
}
if matches!(&shape.def, facet::Def::Slice(_)) {
if let Some(inner) = shape.inner
&& inner == u8::SHAPE
{
return Some(PgType::Bytea);
}
return None;
}
rust_type_to_pg(shape)
}
pub fn rust_type_to_pg(shape: &Shape) -> Option<PgType> {
if shape == i8::SHAPE || shape == u8::SHAPE || shape == i16::SHAPE {
Some(PgType::SmallInt)
} else if shape == u16::SHAPE || shape == i32::SHAPE {
Some(PgType::Integer)
} else if shape == u32::SHAPE
|| shape == i64::SHAPE
|| shape == u64::SHAPE
|| shape == isize::SHAPE
|| shape == usize::SHAPE
{
Some(PgType::BigInt)
} else if shape == f32::SHAPE {
Some(PgType::Real)
} else if shape == f64::SHAPE {
Some(PgType::DoublePrecision)
} else if shape == bool::SHAPE {
Some(PgType::Boolean)
} else if shape == String::SHAPE {
Some(PgType::Text)
} else if shape == rust_decimal::Decimal::SHAPE {
Some(PgType::Numeric)
} else if shape == jiff::Timestamp::SHAPE || shape == jiff::Zoned::SHAPE {
Some(PgType::Timestamptz)
} else if shape == jiff::civil::Date::SHAPE {
Some(PgType::Date)
} else if shape == jiff::civil::Time::SHAPE {
Some(PgType::Time)
} else if shape == chrono::DateTime::<chrono::Utc>::SHAPE
|| shape == chrono::DateTime::<chrono::Local>::SHAPE
|| shape == chrono::NaiveDateTime::SHAPE
{
Some(PgType::Timestamptz)
} else if shape == chrono::NaiveDate::SHAPE {
Some(PgType::Date)
} else if shape == chrono::NaiveTime::SHAPE {
Some(PgType::Time)
} else if shape == uuid::Uuid::SHAPE {
Some(PgType::Uuid)
} else {
None
}
}
inventory::collect!(TableDef);
#[cfg(test)]
mod tests;