Skip to content

Commit 355fea6

Browse files
committed
fix(exporter): Django FK의 attname 충돌과 unique 아닌 참조 컬럼
1 parent 52c6ed6 commit 355fea6

4 files changed

Lines changed: 184 additions & 84 deletions

File tree

‎crates/vespertide-exporter/AGENTS.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,12 @@ repairs.
187187
at that class — and the PK one keeps `primary_key=True`. A key that references anything but
188188
the target's primary key carries `to_field=` (Django would otherwise join on the primary key
189189
and silently return the wrong rows), named through the target's own `column_field_names`. A
190-
key into a model with a composite primary key stays a plain column plus a
190+
key into a model with a composite primary key, or onto a column that is neither the target's
191+
primary key nor unique on its own, stays a plain column plus a
191192
`# foreign key: (col) -> table(ref)` comment: Django cannot relate to such a model
192-
(fields.E347)
193+
(fields.E347) or through such a field (fields.E311). A key claims its attname along with its
194+
field name (`claim_relation_field_name`): Django stores it under `{field}_id`, which a plain
195+
column of that name would clash with (models.E006)
193196
- **Config**: `DjangoExporterWithConfig` for `app_label` (omitted from `Meta` when unset); its
194197
`export` renders the whole schema as one module, which is what the CLI writes (`models.py`) —
195198
Django loads an app's models from its one `models` module

‎crates/vespertide-exporter/src/django/render.rs‎

Lines changed: 177 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
452454
fn 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.
506530
fn 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.
583604
fn 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

597627
fn 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)]
619649
mod 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")]

‎crates/vespertide-exporter/src/django/types.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ pub(super) fn django_field_type(
7676

7777
#[expect(
7878
clippy::too_many_arguments,
79-
reason = "all params are independent field-kwarg inputs; a context struct would add noise without reducing coupling"
79+
reason = "independent field-kwarg inputs, read once at a single call site"
8080
)]
8181
pub(super) fn build_field_kwargs(
8282
col_type: &ColumnType,

‎crates/vespertide-exporter/src/gorm/render.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ fn reverse_field_names(target: &str, rels: &[BackRelation]) -> Vec<String> {
361361

362362
#[expect(
363363
clippy::too_many_arguments,
364-
reason = "all params are independent field-rendering inputs; a context struct would add noise without reducing coupling"
364+
reason = "independent field-rendering inputs, read once at a single call site"
365365
)]
366366
fn render_column_field(
367367
lines: &mut Vec<String>,

0 commit comments

Comments
 (0)