Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 29 additions & 17 deletions crates/ogar-adapter-clickhouse-ddl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ pub fn parse_clickhouse_ddl(_input: &str) -> Result<Vec<Class>, ParseError> {
{
Err(ParseError::Unimplemented(
"clickhouse-parser feature not enabled; rebuild with \
--features clickhouse-parser to enable parsing".into(),
--features clickhouse-parser to enable parsing"
.into(),
))
}
}
Expand Down Expand Up @@ -206,9 +207,7 @@ fn ogar_type_to_clickhouse(t: Option<&str>) -> String {
"Int64".to_string()
}
Some("float") | Some("double") | Some("real") => "Float64".to_string(),
Some("decimal") | Some("monetary") | Some("numeric") => {
"Decimal(18, 4)".to_string()
}
Some("decimal") | Some("monetary") | Some("numeric") => "Decimal(18, 4)".to_string(),
Some("bool") | Some("boolean") => "Bool".to_string(),
Some("datetime") | Some("timestamp") | Some("date") => "DateTime".to_string(),
Some("uuid") => "UUID".to_string(),
Expand All @@ -225,7 +224,10 @@ fn ogar_type_to_clickhouse(t: Option<&str>) -> String {
/// names like `sale.order` need quoting).
fn quote_ch_ident(name: &str) -> String {
let bare = !name.is_empty()
&& name.chars().next().map_or(false, |c| c.is_ascii_alphabetic() || c == '_')
&& name
.chars()
.next()
.map_or(false, |c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if bare {
name.to_string()
Expand Down Expand Up @@ -306,8 +308,7 @@ mod walk {
// nested Nullable (`Nullable(Nullable(X))` is rejected by the
// server), so single-strip is sufficient.
if let Some(inner) = strip_wrapper(&rendered, "nullable") {
let (canonical, _already_optional) =
clickhouse_type_to_ogar(&dummy_data_type(&inner));
let (canonical, _already_optional) = clickhouse_type_to_ogar(&dummy_data_type(&inner));
return (canonical, true);
}

Expand Down Expand Up @@ -403,7 +404,10 @@ mod tests {
let c = Class::new("users");
let ddl = emit_clickhouse_ddl(&[c]);
assert!(ddl.contains("CREATE TABLE users (\n"), "got: {ddl}");
assert!(ddl.contains("ENGINE = MergeTree() ORDER BY tuple();"), "got: {ddl}");
assert!(
ddl.contains("ENGINE = MergeTree() ORDER BY tuple();"),
"got: {ddl}"
);
}

#[test]
Expand All @@ -424,10 +428,7 @@ mod tests {
deleted.options.required = Some(false);
c.attributes.push(deleted);
let ddl = emit_clickhouse_ddl(&[c]);
assert!(
ddl.contains("deleted_at Nullable(DateTime)"),
"got: {ddl}"
);
assert!(ddl.contains("deleted_at Nullable(DateTime)"), "got: {ddl}");
}

#[test]
Expand Down Expand Up @@ -483,13 +484,17 @@ mod tests {
assert_eq!(classes[0].attributes[0].name, "id");
assert_eq!(classes[0].attributes[0].type_name.as_deref(), Some("int"));
assert_eq!(classes[0].attributes[1].name, "name");
assert_eq!(classes[0].attributes[1].type_name.as_deref(), Some("string"));
assert_eq!(
classes[0].attributes[1].type_name.as_deref(),
Some("string")
);
}

#[cfg(feature = "clickhouse-parser")]
#[test]
fn parse_nullable_lifts_to_required_false() {
let ddl = "CREATE TABLE t (deleted_at Nullable(DateTime)) ENGINE = MergeTree ORDER BY tuple();";
let ddl =
"CREATE TABLE t (deleted_at Nullable(DateTime)) ENGINE = MergeTree ORDER BY tuple();";
let classes = parse_clickhouse_ddl(ddl).expect("parse OK");
let attr = &classes[0].attributes[0];
// IR-canonical: bare type + required=Some(false). Same shape
Expand All @@ -507,12 +512,16 @@ mod tests {
i FixedString(16) \
) ENGINE = MergeTree ORDER BY tuple();";
let classes = parse_clickhouse_ddl(ddl).expect("parse OK");
let names: Vec<&str> = classes[0].attributes.iter()
let names: Vec<&str> = classes[0]
.attributes
.iter()
.map(|a| a.type_name.as_deref().unwrap_or(""))
.collect();
assert_eq!(
names,
vec!["string", "int", "int", "float", "decimal", "datetime", "bool", "uuid", "string"],
vec![
"string", "int", "int", "float", "decimal", "datetime", "bool", "uuid", "string"
],
);
}

Expand Down Expand Up @@ -579,7 +588,10 @@ mod tests {
assert_eq!(recovered[0].name, "sale.order");
assert_eq!(recovered[0].attributes.len(), 1);
assert_eq!(recovered[0].attributes[0].name, "amount_total");
assert_eq!(recovered[0].attributes[0].type_name.as_deref(), Some("decimal"));
assert_eq!(
recovered[0].attributes[0].type_name.as_deref(),
Some("decimal")
);
}

#[cfg(feature = "clickhouse-parser")]
Expand Down
15 changes: 12 additions & 3 deletions crates/ogar-adapter-postgres-ddl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ mod tests {
// Nullable: no explicit `required=true` -> no NOT NULL suffix, and
// no incidental match against the NOT NULL variant of the same
// column name.
assert!(ddl.contains("note TEXT") && !ddl.contains("note TEXT NOT NULL"), "got: {ddl}");
assert!(
ddl.contains("note TEXT") && !ddl.contains("note TEXT NOT NULL"),
"got: {ddl}"
);
}

#[test]
Expand All @@ -246,7 +249,10 @@ mod tests {
let ddl = emit_facet_table_ddl("facet");
assert!(ddl.contains("classid INTEGER NOT NULL"), "got: {ddl}");
for i in 0..12 {
assert!(ddl.contains(&format!("p{i} SMALLINT")), "missing p{i}: {ddl}");
assert!(
ddl.contains(&format!("p{i} SMALLINT")),
"missing p{i}: {ddl}"
);
assert!(
ddl.contains(&format!("CREATE INDEX facet_p{i}_idx ON facet (p{i});")),
"missing index for p{i}: {ddl}"
Expand Down Expand Up @@ -290,6 +296,9 @@ mod tests {
assert!(ddl.contains("beschreibung TEXT NOT NULL"), "got: {ddl}");
assert!(ddl.contains("created_at TIMESTAMP"), "got: {ddl}");
assert!(!ddl.contains("created_at TIMESTAMP NOT NULL"), "got: {ddl}");
assert!(!ddl.contains("timesheet_id"), "FK must be deduped upstream: {ddl}");
assert!(
!ddl.contains("timesheet_id"),
"FK must be deduped upstream: {ddl}"
);
}
}
17 changes: 13 additions & 4 deletions crates/ogar-adapter-postgres-ddl/src/parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ pub fn check_parity(sink_in: &Class, legacy: &Class) -> ParityReport {
}

for sink_in_attr in &sink_in.attributes {
if !legacy.attributes.iter().any(|a| a.name == sink_in_attr.name) {
if !legacy
.attributes
.iter()
.any(|a| a.name == sink_in_attr.name)
{
drifts.push(ParityDrift::MissingInLegacy {
field: sink_in_attr.name.clone(),
});
Expand Down Expand Up @@ -165,7 +169,8 @@ mod tests {
#[test]
fn identical_shapes_have_clean_parity() {
let mut c = Class::new("timesheet_activities");
c.attributes.push(attr("beschreibung", "string", Some(true)));
c.attributes
.push(attr("beschreibung", "string", Some(true)));
c.attributes.push(attr("created_at", "datetime", None));

let report = check_parity(&c, &c.clone());
Expand All @@ -181,7 +186,9 @@ mod tests {
let report = check_parity(&sink_in, &legacy);
assert_eq!(
report.drifts,
vec![ParityDrift::MissingInSinkIn { field: "foo".into() }]
vec![ParityDrift::MissingInSinkIn {
field: "foo".into()
}]
);
}

Expand Down Expand Up @@ -241,7 +248,9 @@ mod tests {
sink_in
.attributes
.push(attr("beschreibung", "string", Some(true)));
sink_in.attributes.push(attr("created_at", "datetime", None));
sink_in
.attributes
.push(attr("created_at", "datetime", None));

let mut legacy = Class::new("TimesheetActivity");
legacy
Expand Down
80 changes: 53 additions & 27 deletions crates/ogar-adapter-surrealql/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,7 @@ pub fn parse_surrealql_ddl(_input: &str) -> Result<Vec<Class>, ParseError> {
mod walk {
use ogar_vocab::{Association, AssociationKind, Attribute, Class};
use std::collections::HashMap;
use surrealdb_ast::{
Ast, Expr, NodeId, NodeListId, PrimeType, Query, TopLevelExpr, Type,
};
use surrealdb_ast::{Ast, Expr, NodeId, NodeListId, PrimeType, Query, TopLevelExpr, Type};

pub(super) fn walk_query(ast: &Ast, query: Query) -> Vec<Class> {
// Pass 1: visit DefineTable to register class order.
Expand All @@ -233,7 +231,10 @@ mod walk {
}
}

order.into_iter().filter_map(|n| by_name.remove(&n)).collect()
order
.into_iter()
.filter_map(|n| by_name.remove(&n))
.collect()
}

fn visit_define(
Expand Down Expand Up @@ -271,7 +272,10 @@ mod walk {
});

match lift_field_type(ast, df.ty) {
FieldShape::Primitive { type_name, optional } => {
FieldShape::Primitive {
type_name,
optional,
} => {
let mut attr = Attribute::new(&field);
attr.type_name = Some(type_name);
if optional {
Expand All @@ -282,8 +286,7 @@ mod walk {
class.attributes.push(attr);
}
FieldShape::Record { target, optional } => {
let mut assoc =
Association::new(AssociationKind::BelongsTo, &field);
let mut assoc = Association::new(AssociationKind::BelongsTo, &field);
assoc.class_name = Some(target);
if optional {
assoc.optional = Some(true);
Expand Down Expand Up @@ -360,19 +363,22 @@ mod walk {
None => return FieldShape::Untyped,
};
let prime_list_id = match &ast[ty_id] {
Type::Any(_) => return FieldShape::Primitive {
type_name: "any".into(),
optional: false,
},
Type::Any(_) => {
return FieldShape::Primitive {
type_name: "any".into(),
optional: false,
};
}
Type::Prime(list_id) => *list_id,
};

// The parser encodes `option<X>` as a NodeList prefixed with
// PrimeType::None (see surrealdb/parser/src/parse/kind.rs at
// the T![OPTION] arm). So a NodeList starting with None means
// optional; the rest are the effective type(s).
let primes: Vec<&PrimeType> =
iter_node_list(ast, prime_list_id).map(|p| &ast[p]).collect();
let primes: Vec<&PrimeType> = iter_node_list(ast, prime_list_id)
.map(|p| &ast[p])
.collect();
if primes.is_empty() {
return FieldShape::Untyped;
}
Expand Down Expand Up @@ -477,10 +483,11 @@ impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Parse(msg) => write!(f, "surrealql parse error: {msg}"),
ParseError::UnmappableField { table, field, reason } => write!(
f,
"unmappable field {field} on table {table}: {reason}"
),
ParseError::UnmappableField {
table,
field,
reason,
} => write!(f, "unmappable field {field} on table {table}: {reason}"),
ParseError::Unimplemented(msg) => write!(f, "not yet implemented: {msg}"),
}
}
Expand Down Expand Up @@ -622,7 +629,10 @@ fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) {
"DEFINE FIELD {column_ident} ON {table} TYPE string ASSERT $value IN [{variants}];\n"
));
}
ogar_vocab::EnumSource::Add { items, parent_selection } => {
ogar_vocab::EnumSource::Add {
items,
parent_selection,
} => {
// Inherited enum: emit the added variants only; downstream
// consumers reconcile against the parent via `parent_selection`.
let variants = items
Expand Down Expand Up @@ -727,7 +737,10 @@ mod tests {
fn emit_minimal_class_produces_define_table() {
let c = Class::new("widget");
let ddl = emit_surrealql_ddl(&[c]);
assert!(ddl.contains("DEFINE TABLE widget SCHEMAFULL;"), "got: {ddl}");
assert!(
ddl.contains("DEFINE TABLE widget SCHEMAFULL;"),
"got: {ddl}"
);
}

#[test]
Expand All @@ -738,7 +751,10 @@ mod tests {
c.attributes.push(email);
let ddl = emit_surrealql_ddl(&[c]);
assert!(ddl.contains("DEFINE TABLE account SCHEMAFULL;"));
assert!(ddl.contains("DEFINE FIELD email ON account TYPE string;"), "got: {ddl}");
assert!(
ddl.contains("DEFINE FIELD email ON account TYPE string;"),
"got: {ddl}"
);
}

#[test]
Expand Down Expand Up @@ -806,7 +822,10 @@ mod tests {
.push(Association::new(AssociationKind::HasOne, "lead"));
let ddl = emit_surrealql_ddl(&[c]);
assert!(!ddl.contains("DEFINE FIELD lead"), "got: {ddl}");
assert!(ddl.contains("(no DEFINE FIELD"), "expected non-owning comment, got: {ddl}");
assert!(
ddl.contains("(no DEFINE FIELD"),
"expected non-owning comment, got: {ddl}"
);
}

#[test]
Expand All @@ -820,7 +839,9 @@ mod tests {
c.enums.push(status);
let ddl = emit_surrealql_ddl(&[c]);
assert!(
ddl.contains("DEFINE FIELD status ON ticket TYPE string ASSERT $value IN ['open', 'closed'];"),
ddl.contains(
"DEFINE FIELD status ON ticket TYPE string ASSERT $value IN ['open', 'closed'];"
),
"got: {ddl}"
);
}
Expand All @@ -846,9 +867,8 @@ mod tests {
fn emit_class_with_computed_enum_emits_lambda_marker() {
let mut c = Class::new("address");
let mut country_enum = EnumDecl::new("country");
country_enum.source = ogar_vocab::EnumSource::Computed(
"lambda self: self.env[\'res.country\']...".into(),
);
country_enum.source =
ogar_vocab::EnumSource::Computed("lambda self: self.env[\'res.country\']...".into());
c.enums.push(country_enum);
let ddl = emit_surrealql_ddl(&[c]);
assert!(
Expand Down Expand Up @@ -964,13 +984,19 @@ mod tests {
assert_eq!(classes.len(), 1);
let c = &classes[0];
assert_eq!(c.name, "work_package");
assert!(c.attributes.is_empty(), "record<X> should NOT become an attribute");
assert!(
c.attributes.is_empty(),
"record<X> should NOT become an attribute"
);
assert_eq!(c.associations.len(), 1);
let a = &c.associations[0];
assert_eq!(a.name, "owner");
assert!(matches!(a.kind, AssociationKind::BelongsTo));
assert_eq!(a.class_name.as_deref(), Some("user"));
assert!(a.optional.unwrap_or(false) == false, "non-optional record<X>");
assert!(
a.optional.unwrap_or(false) == false,
"non-optional record<X>"
);
}

#[cfg(feature = "surrealdb-parser")]
Expand Down
Loading
Loading