@@ -199,22 +199,21 @@ fn render_entity_part(
199199 ) ) ;
200200 }
201201 let attname = if let Some ( fk) = fk. filter ( |fk| is_relatable ( fk, schema) ) {
202- let default = col
203- . default
204- . as_ref ( )
205- . and_then ( |dv| build_default ( & col. r#type , & dv. to_sql ( ) , used) ) ;
206- render_fk_field (
207- & mut lines,
208- & col. name ,
209- field_name,
210- & class_of ( names, fk. ref_table ) ,
211- to_field ( fk, schema) . as_deref ( ) ,
212- fk. on_delete ,
213- default. as_deref ( ) ,
214- effective_pk,
202+ let field = ForeignKeyField {
203+ column : & col. name ,
204+ name : field_name,
205+ target_class : class_of ( names, fk. ref_table ) ,
206+ to_field : to_field ( fk, schema) ,
207+ on_delete : fk. on_delete ,
208+ default : col
209+ . default
210+ . as_ref ( )
211+ . and_then ( |dv| build_default ( & col. r#type , & dv. to_sql ( ) , used) ) ,
212+ is_pk : effective_pk,
215213 is_unique,
216- col. nullable ,
217- ) ;
214+ nullable : col. nullable ,
215+ } ;
216+ lines. push ( field. render ( ) ) ;
218217 // A ForeignKey's attname is `{field}_id` whatever `db_column` says.
219218 format ! ( "{field_name}_id" )
220219 } else {
@@ -390,70 +389,78 @@ fn render_entity_part(
390389 lines. join ( "\n " )
391390}
392391
393- #[ expect(
394- clippy:: too_many_arguments,
395- reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling"
396- ) ]
397- fn render_fk_field (
398- lines : & mut Vec < String > ,
399- col_name : & str ,
400- field_name : & str ,
401- ref_class : & str ,
402- to_field : Option < & str > ,
403- on_delete : Option < & ReferenceAction > ,
404- default : Option < & str > ,
392+ /// A single-column foreign key Django can express, as the field it renders.
393+ struct ForeignKeyField < ' a > {
394+ column : & ' a str ,
395+ name : & ' a str ,
396+ target_class : String ,
397+ to_field : Option < String > ,
398+ on_delete : Option < & ' a ReferenceAction > ,
399+ default : Option < String > ,
405400 is_pk : bool ,
406401 is_unique : bool ,
407402 nullable : bool ,
408- ) {
409- // Django reads a ForeignKey through `{field}_id`, so the column keeps its
410- // database name exactly when the stripped base survives every rename.
411- let db_column = ( format ! ( "{field_name}_id" ) != col_name) . then ( || col_name. to_string ( ) ) ;
412- let null = nullable && !is_pk;
413- // `ON UPDATE` has no counterpart on a Django ForeignKey.
414- let on_delete_str = on_delete_for ( on_delete, default. is_some ( ) , null) ;
415-
416- let mut kwargs = vec ! [
417- format!( "\" {ref_class}\" " ) ,
418- format!( "on_delete={on_delete_str}" ) ,
419- ] ;
420- if let Some ( to_field) = to_field {
421- kwargs. push ( format ! ( "to_field={}" , string_literal( to_field) ) ) ;
422- }
423- if is_pk {
424- kwargs. push ( "primary_key=True" . into ( ) ) ;
425- }
426- if let Some ( default) = default {
427- kwargs. push ( format ! ( "default={default}" ) ) ;
428- }
429- if let Some ( db_col) = db_column {
430- kwargs. push ( format ! ( "db_column={}" , string_literal( & db_col) ) ) ;
431- }
432- kwargs. push ( "related_name=\" +\" " . into ( ) ) ;
433- if null {
434- kwargs. push ( "null=True" . into ( ) ) ;
435- kwargs. push ( "blank=True" . into ( ) ) ;
436- }
403+ }
437404
438- // A FK that is the PK or unique holds at most one row per target: Django's
439- // one-to-one. `ForeignKey(unique=True)` only draws fields.W342 pointing here.
440- let field_class = if is_pk || is_unique {
441- "models.OneToOneField"
442- } else {
443- "models.ForeignKey"
444- } ;
445- let kwargs_str = kwargs. join ( ", " ) ;
446- lines. push ( format ! ( " {field_name} = {field_class}({kwargs_str})" ) ) ;
405+ impl ForeignKeyField < ' _ > {
406+ fn render ( & self ) -> String {
407+ // Django reads a ForeignKey through `{field}_id`, so the column keeps
408+ // its database name exactly when the stripped base survives every
409+ // rename.
410+ let db_column = ( format ! ( "{}_id" , self . name) != self . column ) . then_some ( self . column ) ;
411+ let null = self . nullable && !self . is_pk ;
412+ // `ON UPDATE` has no counterpart on a Django ForeignKey.
413+ let on_delete = on_delete_for ( self . on_delete , self . default . is_some ( ) , null) ;
414+
415+ let mut kwargs = vec ! [
416+ format!( "\" {}\" " , self . target_class) ,
417+ format!( "on_delete={on_delete}" ) ,
418+ ] ;
419+ if let Some ( to_field) = & self . to_field {
420+ kwargs. push ( format ! ( "to_field={}" , string_literal( to_field) ) ) ;
421+ }
422+ if self . is_pk {
423+ kwargs. push ( "primary_key=True" . into ( ) ) ;
424+ }
425+ if let Some ( default) = & self . default {
426+ kwargs. push ( format ! ( "default={default}" ) ) ;
427+ }
428+ if let Some ( db_column) = db_column {
429+ kwargs. push ( format ! ( "db_column={}" , string_literal( db_column) ) ) ;
430+ }
431+ kwargs. push ( "related_name=\" +\" " . into ( ) ) ;
432+ if null {
433+ kwargs. push ( "null=True" . into ( ) ) ;
434+ kwargs. push ( "blank=True" . into ( ) ) ;
435+ }
436+
437+ // A FK that is the PK or unique holds at most one row per target:
438+ // Django's one-to-one. `ForeignKey(unique=True)` only draws fields.W342
439+ // pointing here.
440+ let field_class = if self . is_pk || self . is_unique {
441+ "models.OneToOneField"
442+ } else {
443+ "models.ForeignKey"
444+ } ;
445+ format ! ( " {} = {field_class}({})" , self . name, kwargs. join( ", " ) )
446+ }
447447}
448448
449- /// Django cannot relate to a model with a composite primary key
450- /// (fields.E347), so a foreign key into one stays a plain column. A target
451- /// outside `schema` is taken at its word.
449+ /// Whether Django can express `fk` as a relation. It cannot relate to a model
450+ /// with a composite primary key (fields.E347), and the field a key references
451+ /// must be unique (fields.E311): the target's primary key, or a column with a
452+ /// unique of its own. Such a key stays a plain column. A target outside
453+ /// `schema` is taken at its word.
452454fn is_relatable ( fk : & FkDetails , schema : & [ TableDef ] ) -> bool {
453455 schema
454456 . iter ( )
455457 . find ( |t| t. name . as_str ( ) == fk. ref_table )
456- . is_none_or ( |target| primary_key_columns ( & target. constraints ) . len ( ) < 2 )
458+ . is_none_or ( |target| {
459+ let pk = primary_key_columns ( & target. constraints ) ;
460+ pk. len ( ) < 2
461+ && ( pk. contains ( fk. ref_column )
462+ || single_column_uniques ( & target. constraints ) . contains ( fk. ref_column ) )
463+ } )
457464}
458465
459466/// The `to_field` a foreign key needs: the target's field for the referenced
@@ -491,17 +498,34 @@ fn column_field_names<'a>(
491498 let is_relation = fk_map
492499 . get ( col. name . as_str ( ) )
493500 . is_some_and ( |fk| is_relatable ( fk, schema) ) ;
494- let base = if is_relation {
495- vespertide_naming:: infer_relation_field_name ( & col. name )
501+ let name = if is_relation {
502+ let base = vespertide_naming:: infer_relation_field_name ( & col. name ) ;
503+ claim_relation_field_name ( & django_identifier ( base) , & mut taken)
496504 } else {
497- col. name . as_str ( )
505+ django_field_name ( & col. name , & mut taken )
498506 } ;
499- ( col. name . as_str ( ) , django_field_name ( base , & mut taken ) )
507+ ( col. name . as_str ( ) , name )
500508 } )
501509 . collect ( ) ;
502510 ( names, taken)
503511}
504512
513+ /// Claim a relation's field name together with its attname: Django stores the
514+ /// key under `{field}_id`, so a plain `owner_id` column next to an `owner` key
515+ /// would share that attribute with it (models.E006). The first numbered name
516+ /// with both free wins.
517+ fn claim_relation_field_name ( preferred : & str , taken : & mut HashSet < String > ) -> String {
518+ let mut name = preferred. to_string ( ) ;
519+ let mut n = 2usize ;
520+ while taken. contains ( & name) || taken. contains ( & format ! ( "{name}_id" ) ) {
521+ name = format ! ( "{preferred}{n}" ) ;
522+ n += 1 ;
523+ }
524+ taken. insert ( format ! ( "{name}_id" ) ) ;
525+ taken. insert ( name. clone ( ) ) ;
526+ name
527+ }
528+
505529/// Every class `tables` declare in their module: models, then choices classes.
506530fn module_names ( tables : & [ TableDef ] ) -> ScopeNames {
507531 ScopeNames :: collect ( tables, model_class_name, enum_class_name)
@@ -574,13 +598,19 @@ const MODEL_ATTRIBUTES: &[&str] = &[
574598 "validate_unique" ,
575599] ;
576600
577- /// A column's Django field name: a Python identifier that also passes Django's
578- /// field checks — no `__` (the lookup separator, fields.E002), no trailing `_`
579- /// (fields.E001), not a keyword and not one of the model's own attributes —
580- /// claimed against `taken`. The repairs are `inspectdb`'s, so a renamed field
581- /// reads the way Django's own tooling would spell it; callers emit `db_column`
582- /// whenever the result differs from the column.
601+ /// A column's Django field name: its [`django_identifier`], claimed against
602+ /// `taken`. Callers emit `db_column` whenever the result differs from the
603+ /// column.
583604fn django_field_name ( column : & str , taken : & mut HashSet < String > ) -> String {
605+ claim_binding ( django_identifier ( column) , taken)
606+ }
607+
608+ /// A Python identifier that also passes Django's field checks — no `__` (the
609+ /// lookup separator, fields.E002), no trailing `_` (fields.E001), not a
610+ /// keyword and not one of the model's own attributes. The repairs are
611+ /// `inspectdb`'s, so a renamed field reads the way Django's own tooling would
612+ /// spell it.
613+ fn django_identifier ( column : & str ) -> String {
584614 let mut name = sanitize_identifier ( column, IdentifierStart :: Underscore ) ;
585615 while name. contains ( "__" ) {
586616 name = name. replace ( "__" , "_" ) ;
@@ -591,7 +621,7 @@ fn django_field_name(column: &str, taken: &mut HashSet<String>) -> String {
591621 if MODEL_ATTRIBUTES . contains ( & name. as_str ( ) ) || is_python_keyword ( & name) {
592622 name. push_str ( "_field" ) ;
593623 }
594- claim_binding ( name, taken )
624+ name
595625}
596626
597627fn assemble_with_imports ( used : & UsedImports , parts : & [ String ] ) -> String {
@@ -617,7 +647,10 @@ fn assemble_with_imports(used: &UsedImports, parts: &[String]) -> String {
617647
618648#[ cfg( test) ]
619649mod tests {
650+ use vespertide_core:: schema:: column:: SimpleColumnType ;
651+
620652 use super :: * ;
653+ use crate :: tests:: fixtures:: { fk, pk, simple} ;
621654
622655 #[ rstest:: rstest]
623656 #[ case:: plain( "author" , "author" ) ]
@@ -636,6 +669,70 @@ mod tests {
636669 assert_eq ! ( django_field_name( column, & mut taken) , expected) ;
637670 }
638671
672+ fn owners ( constraints : Vec < TableConstraint > ) -> TableDef {
673+ TableDef {
674+ name : "owners" . into ( ) ,
675+ description : None ,
676+ columns : vec ! [
677+ simple( "id" , SimpleColumnType :: Integer ) ,
678+ simple( "region" , SimpleColumnType :: Integer ) ,
679+ simple( "code" , SimpleColumnType :: Integer ) ,
680+ ] ,
681+ constraints,
682+ }
683+ }
684+
685+ fn unique ( column : & str ) -> TableConstraint {
686+ TableConstraint :: Unique {
687+ name : None ,
688+ columns : vec ! [ column. into( ) ] ,
689+ strategy : vespertide_core:: UniqueConstraintStrategy :: DeleteDuplicates {
690+ keep : vespertide_core:: KeepPolicy :: First ,
691+ } ,
692+ }
693+ }
694+
695+ /// A key's attname is `{field}_id`, so the key and a column of that name
696+ /// cannot both keep theirs, whichever the table declares first.
697+ #[ rstest:: rstest]
698+ #[ case:: key_first( & [ "owner" , "owner_id" ] , & [ "owner" , "owner_id2" ] ) ]
699+ #[ case:: column_first( & [ "owner_id" , "owner" ] , & [ "owner_id" , "owner2" ] ) ]
700+ fn a_key_claims_its_attname_with_its_field_name (
701+ #[ case] columns : & [ & str ] ,
702+ #[ case] expected : & [ & str ] ,
703+ ) {
704+ let table = TableDef {
705+ name : "pets" . into ( ) ,
706+ description : None ,
707+ columns : columns
708+ . iter ( )
709+ . map ( |name| simple ( name, SimpleColumnType :: Integer ) )
710+ . collect ( ) ,
711+ constraints : vec ! [ fk( & [ "owner" ] , "owners" , & [ "id" ] ) ] ,
712+ } ;
713+ let ( names, _) = column_field_names ( & table, & [ ] ) ;
714+ let names: Vec < & str > = columns. iter ( ) . map ( |c| names[ c] . as_str ( ) ) . collect ( ) ;
715+ assert_eq ! ( names, expected) ;
716+ }
717+
718+ #[ rstest:: rstest]
719+ #[ case:: primary_key( vec![ pk( & [ "id" ] ) ] , "id" , true ) ]
720+ #[ case:: unique_column( vec![ pk( & [ "id" ] ) , unique( "code" ) ] , "code" , true ) ]
721+ #[ case:: column_that_is_not_unique( vec![ pk( & [ "id" ] ) ] , "code" , false ) ]
722+ #[ case:: part_of_a_composite_key( vec![ pk( & [ "id" , "region" ] ) ] , "id" , false ) ]
723+ fn a_key_is_a_relation_only_onto_a_unique_field_of_a_single_key_model (
724+ #[ case] target_constraints : Vec < TableConstraint > ,
725+ #[ case] ref_column : & str ,
726+ #[ case] expected : bool ,
727+ ) {
728+ let schema = [ owners ( target_constraints) ] ;
729+ let key = fk ( & [ "owner_id" ] , "owners" , & [ ref_column] ) ;
730+ let fk_map = single_column_fk_details ( std:: slice:: from_ref ( & key) ) ;
731+ assert_eq ! ( is_relatable( & fk_map[ "owner_id" ] , & schema) , expected) ;
732+ // A target the schema does not hold is taken at its word.
733+ assert ! ( is_relatable( & fk_map[ "owner_id" ] , & [ ] ) ) ;
734+ }
735+
639736 #[ rstest:: rstest]
640737 #[ case:: plain( "order_status" , "OrderStatus" ) ]
641738 #[ case:: digit_led( "1st" , "_1st" ) ]
0 commit comments