Skip to content

Commit a25812a

Browse files
authored
Merge pull request #36 from AdaWorldAPI/claude/fix-ddl-identifier-quoting
fix(ogar-adapter-surrealql): quote non-bare identifiers in emitted DDL
2 parents 3506751 + bd6ee1e commit a25812a

1 file changed

Lines changed: 179 additions & 14 deletions

File tree

  • crates/ogar-adapter-surrealql/src

crates/ogar-adapter-surrealql/src/lib.rs

Lines changed: 179 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -499,26 +499,31 @@ fn emit_class(class: &Class, out: &mut String) {
499499
// SCHEMAFULL is the safe default for OGAR-produced classes — they have
500500
// a typed shape. If a producer needs SCHEMALESS it can wire it through
501501
// a Class decorator and we extend here.
502-
out.push_str(&format!("DEFINE TABLE {} SCHEMAFULL", class.name));
502+
let table_ident = surrealql_ident(&class.name);
503+
out.push_str(&format!("DEFINE TABLE {table_ident} SCHEMAFULL"));
503504
if let Some(desc) = &class.description {
504505
out.push_str(&format!(" COMMENT {}", surreal_string_literal(desc)));
505506
}
506507
out.push_str(";\n");
507508

508509
// DEFINE FIELD per Attribute
509510
for attr in &class.attributes {
510-
emit_field_attr(&class.name, attr, out);
511+
emit_field_attr(&table_ident, attr, out);
511512
}
512513
// DEFINE FIELD per Association (renders as record<target>)
513514
for assoc in &class.associations {
514-
emit_field_assoc(&class.name, assoc, out);
515+
emit_field_assoc(&table_ident, assoc, out);
515516
}
516517
// DEFINE FIELD per EnumDecl (renders as string ASSERT $value IN [...])
517518
for enum_decl in &class.enums {
518-
emit_field_enum(&class.name, enum_decl, out);
519+
emit_field_enum(&table_ident, enum_decl, out);
519520
}
520521
}
521522

523+
// NOTE: `table` arrives already passed through `surrealql_ident` at the
524+
// `emit_class` call site (each identifier quoted once at its source).
525+
// Field-side identifiers (`attr.name`, `assoc.name`, target record name,
526+
// enum column) are quoted locally here.
522527
fn emit_field_attr(table: &str, attr: &Attribute, out: &mut String) {
523528
let surreal_type = attr
524529
.type_name
@@ -537,7 +542,9 @@ fn emit_field_attr(table: &str, attr: &Attribute, out: &mut String) {
537542
};
538543
out.push_str(&format!(
539544
"DEFINE FIELD {} ON {} TYPE {};\n",
540-
attr.name, table, wrapped
545+
surrealql_ident(&attr.name),
546+
table,
547+
wrapped
541548
));
542549
}
543550

@@ -552,20 +559,27 @@ fn emit_field_assoc(table: &str, assoc: &Association, out: &mut String) {
552559
.class_name
553560
.as_deref()
554561
.unwrap_or(&assoc.name); // fallback: relation name as target
562+
// The record target is a SurrealQL identifier — quote if
563+
// non-bare (Odoo `res.partner` → `` `res.partner` ``).
564+
let target_ident = surrealql_ident(target);
555565
let ty = if assoc.optional.unwrap_or(false) {
556-
format!("option<record<{target}>>")
566+
format!("option<record<{target_ident}>>")
557567
} else {
558-
format!("record<{target}>")
568+
format!("record<{target_ident}>")
559569
};
560570
out.push_str(&format!(
561571
"DEFINE FIELD {} ON {} TYPE {};\n",
562-
assoc.name, table, ty
572+
surrealql_ident(&assoc.name),
573+
table,
574+
ty
563575
));
564576
}
565577
AssociationKind::HasOne | AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
566578
// Non-owning / join-table sides: no field on this table.
567579
// Roundtrip note for unmap: the inverse side reconstructs from
568580
// the owning side's `record<X>` field on the target table.
581+
// The comment body isn't parsed; leave names un-quoted for
582+
// readability.
569583
out.push_str(&format!(
570584
"-- {} {:?} {} (no DEFINE FIELD — non-owning / join side)\n",
571585
table, assoc.kind, assoc.name
@@ -582,6 +596,7 @@ fn emit_field_assoc(table: &str, assoc: &Association, out: &mut String) {
582596
fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) {
583597
// Per `ogar-vocab::EnumDecl`: `column` names the field; `source`
584598
// carries the variant list (Static / Computed / Add).
599+
let column_ident = surrealql_ident(&enum_decl.column);
585600
match &enum_decl.source {
586601
ogar_vocab::EnumSource::Static(items) => {
587602
let variants = items
@@ -590,8 +605,7 @@ fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) {
590605
.collect::<Vec<_>>()
591606
.join(", ");
592607
out.push_str(&format!(
593-
"DEFINE FIELD {} ON {} TYPE string ASSERT $value IN [{}];\n",
594-
enum_decl.column, table, variants
608+
"DEFINE FIELD {column_ident} ON {table} TYPE string ASSERT $value IN [{variants}];\n"
595609
));
596610
}
597611
ogar_vocab::EnumSource::Add { items, parent_selection } => {
@@ -603,8 +617,7 @@ fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) {
603617
.collect::<Vec<_>>()
604618
.join(", ");
605619
out.push_str(&format!(
606-
"DEFINE FIELD {} ON {} TYPE string /* selection_add from {} */ ASSERT $value IN [{}];\n",
607-
enum_decl.column, table, parent_selection, variants
620+
"DEFINE FIELD {column_ident} ON {table} TYPE string /* selection_add from {parent_selection} */ ASSERT $value IN [{variants}];\n"
608621
));
609622
}
610623
ogar_vocab::EnumSource::Computed(body) => {
@@ -613,8 +626,7 @@ fn emit_field_enum(table: &str, enum_decl: &EnumDecl, out: &mut String) {
613626
// side to reconstitute.
614627
let escaped = body.replace("*/", "* /");
615628
out.push_str(&format!(
616-
"DEFINE FIELD {} ON {} TYPE string /* computed: {} */;\n",
617-
enum_decl.column, table, escaped
629+
"DEFINE FIELD {column_ident} ON {table} TYPE string /* computed: {escaped} */;\n"
618630
));
619631
}
620632
// `EnumSource` is `#[non_exhaustive]` in `ogar-vocab`; the three arms
@@ -655,6 +667,39 @@ fn surreal_string_literal(s: &str) -> String {
655667
format!("'{escaped}'")
656668
}
657669

670+
/// Render a name as a SurrealQL identifier: bare if it matches the
671+
/// `[A-Za-z_][A-Za-z0-9_]*` bare-identifier subset, backtick-quoted
672+
/// otherwise.
673+
///
674+
/// OGAR class names can carry dots (Odoo dotted names like
675+
/// `sale.order` / `account.move.line`), hyphens, or other characters
676+
/// that aren't valid as SurrealQL bare identifiers. Closes Codex P2
677+
/// on PR #33: enabling `surrealql-hint` for Odoo-style classes was
678+
/// rendering invalid DDL (`DEFINE TABLE sale.order …` — the dot
679+
/// terminates the identifier).
680+
///
681+
/// The backtick-quoted form is the canonical SurrealQL non-bare
682+
/// identifier syntax; embedded backticks are escaped by doubling
683+
/// (`` `foo``bar` `` for an identifier containing one backtick).
684+
/// Round-trip safe: `parse_surrealql_ddl` reads the backtick-quoted
685+
/// form back into the same `Ident.text`.
686+
fn surrealql_ident(name: &str) -> String {
687+
if is_bare_surrealql_identifier(name) {
688+
name.to_string()
689+
} else {
690+
format!("`{}`", name.replace('`', "``"))
691+
}
692+
}
693+
694+
fn is_bare_surrealql_identifier(s: &str) -> bool {
695+
let mut chars = s.chars();
696+
match chars.next() {
697+
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
698+
_ => return false,
699+
}
700+
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
701+
}
702+
658703
// ─────────────────────────────────────────────────────────────────────
659704
// Tests — lock the emit shape; parse_surrealql_ddl tests land with the
660705
// parser wiring.
@@ -1083,4 +1128,124 @@ mod tests {
10831128
other => panic!("expected Unimplemented(feature off), got: {other:?}"),
10841129
}
10851130
}
1131+
1132+
// ── Codex P2 fix (PR #33) ──────────────────────────────────────────
1133+
// OGAR class names like `sale.order` (Odoo dotted form) aren't
1134+
// valid SurrealQL bare identifiers — the dot terminates the
1135+
// identifier. `surrealql_ident()` quotes them with backticks; the
1136+
// emit sites use it everywhere a Class/Attribute/Association/Enum
1137+
// name lands in DDL.
1138+
// ───────────────────────────────────────────────────────────────────
1139+
1140+
#[test]
1141+
fn bare_identifier_passes_through_unquoted() {
1142+
assert_eq!(surrealql_ident("widget"), "widget");
1143+
assert_eq!(surrealql_ident("WorkPackage"), "WorkPackage");
1144+
assert_eq!(surrealql_ident("work_package"), "work_package");
1145+
assert_eq!(surrealql_ident("_internal"), "_internal");
1146+
assert_eq!(surrealql_ident("a1b2c3"), "a1b2c3");
1147+
}
1148+
1149+
#[test]
1150+
fn non_bare_identifier_gets_backtick_quoted() {
1151+
// Odoo dotted names — the Codex P2 motivating case.
1152+
assert_eq!(surrealql_ident("sale.order"), "`sale.order`");
1153+
assert_eq!(surrealql_ident("account.move.line"), "`account.move.line`");
1154+
assert_eq!(surrealql_ident("res.partner"), "`res.partner`");
1155+
// Hyphens, leading digits, spaces — all need quoting.
1156+
assert_eq!(surrealql_ident("kebab-case"), "`kebab-case`");
1157+
assert_eq!(surrealql_ident("3leading"), "`3leading`");
1158+
assert_eq!(surrealql_ident("with space"), "`with space`");
1159+
}
1160+
1161+
#[test]
1162+
fn embedded_backticks_get_doubled() {
1163+
// Pathological case: ident contains a backtick. Double it
1164+
// per SurrealQL convention.
1165+
assert_eq!(surrealql_ident("weird`name"), "`weird``name`");
1166+
}
1167+
1168+
#[test]
1169+
fn empty_string_is_not_a_bare_identifier() {
1170+
assert!(!is_bare_surrealql_identifier(""));
1171+
assert_eq!(surrealql_ident(""), "``");
1172+
}
1173+
1174+
#[test]
1175+
fn emit_class_with_odoo_dotted_name_quotes_the_table() {
1176+
let mut c = Class::new("sale.order");
1177+
let mut amount = Attribute::new("amount_total");
1178+
amount.type_name = Some("decimal".into());
1179+
c.attributes.push(amount);
1180+
let ddl = emit_surrealql_ddl(&[c]);
1181+
// Critical: the table identifier is backtick-quoted in BOTH
1182+
// the DEFINE TABLE and the DEFINE FIELD ON clause.
1183+
assert!(
1184+
ddl.contains("DEFINE TABLE `sale.order` SCHEMAFULL"),
1185+
"expected backtick-quoted DEFINE TABLE, got: {ddl}"
1186+
);
1187+
assert!(
1188+
ddl.contains("DEFINE FIELD amount_total ON `sale.order` TYPE decimal;"),
1189+
"expected backtick-quoted ON clause, got: {ddl}"
1190+
);
1191+
}
1192+
1193+
#[test]
1194+
fn emit_class_with_odoo_belongs_to_quotes_record_target() {
1195+
// The record target name also needs quoting: record<`res.partner`>
1196+
let mut c = Class::new("sale.order");
1197+
let mut partner = Association::new(AssociationKind::BelongsTo, "partner_id");
1198+
partner.class_name = Some("res.partner".into());
1199+
c.associations.push(partner);
1200+
let ddl = emit_surrealql_ddl(&[c]);
1201+
assert!(
1202+
ddl.contains("DEFINE FIELD partner_id ON `sale.order` TYPE record<`res.partner`>;"),
1203+
"expected quoted target in record<…>, got: {ddl}"
1204+
);
1205+
}
1206+
1207+
#[cfg(feature = "surrealdb-parser")]
1208+
#[test]
1209+
fn round_trip_odoo_dotted_table_name() {
1210+
// Build IR with an Odoo dotted name + a dotted-name record
1211+
// target. Emit → parse → assert the IR survives the trip.
1212+
// Without `surrealql_ident`, emit would produce invalid DDL
1213+
// (`DEFINE TABLE sale.order …`) and the parser would either
1214+
// reject it or mis-parse the dot as a path operator — the
1215+
// bug Codex P2 flagged on PR #33.
1216+
let mut order = Class::new("sale.order");
1217+
let mut partner_id = Association::new(AssociationKind::BelongsTo, "partner_id");
1218+
partner_id.class_name = Some("res.partner".into());
1219+
order.associations.push(partner_id);
1220+
let mut amount = Attribute::new("amount_total");
1221+
amount.type_name = Some("decimal".into());
1222+
order.attributes.push(amount);
1223+
1224+
let ddl = emit_surrealql_ddl(&[order]);
1225+
let recovered = parse_surrealql_ddl(&ddl).expect("parse OK");
1226+
assert_eq!(recovered.len(), 1);
1227+
let r = &recovered[0];
1228+
assert_eq!(r.name, "sale.order");
1229+
assert_eq!(r.attributes.len(), 1);
1230+
assert_eq!(r.attributes[0].name, "amount_total");
1231+
assert_eq!(r.attributes[0].type_name.as_deref(), Some("decimal"));
1232+
assert_eq!(r.associations.len(), 1);
1233+
assert_eq!(r.associations[0].name, "partner_id");
1234+
assert_eq!(r.associations[0].class_name.as_deref(), Some("res.partner"));
1235+
}
1236+
1237+
#[cfg(feature = "surrealdb-parser")]
1238+
#[test]
1239+
fn round_trip_three_segment_odoo_name() {
1240+
// `account.move.line` — three-segment dotted name.
1241+
let c = Class::new("account.move.line");
1242+
let ddl = emit_surrealql_ddl(&[c]);
1243+
assert!(
1244+
ddl.contains("`account.move.line`"),
1245+
"three-segment name must be quoted, got: {ddl}"
1246+
);
1247+
let recovered = parse_surrealql_ddl(&ddl).expect("parse OK");
1248+
assert_eq!(recovered.len(), 1);
1249+
assert_eq!(recovered[0].name, "account.move.line");
1250+
}
10861251
}

0 commit comments

Comments
 (0)