@@ -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.
522527fn 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) {
582596fn 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