An Ent extension that enforces fourth normal form (4NF): all relationships must go through explicit join table schemas. No foreign key columns on entity tables.
go get github.com/blobmasterbrian/fournf//go:build ignore
package main
import (
"log"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/blobmasterbrian/fournf"
)
func main() {
ext, err := fournf.NewExtension()
if err != nil {
log.Fatalf("creating fournf extension: %v", err)
}
err = entc.Generate("./schema",
&gen.Config{},
entc.Extensions(ext),
)
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}go generate will now fail if any schema violates 4NF.
Entity schemas define your domain models. Join table schemas wire relationships
between them and are annotated with fournf.JoinTable().
// schema/species.go
package schema
type Species struct { ent.Schema }
func (Species) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
}
}
func (Species) Edges() []ent.Edge {
return []ent.Edge{
edge.From("habitats", Habitat.Type).
Ref("species"),
}
}// schema/habitat.go
package schema
type Habitat struct { ent.Schema }
func (Habitat) Fields() []ent.Field {
return []ent.Field{
field.String("name"), // e.g. "Rainforest", "Tundra"
}
}
func (Habitat) Edges() []ent.Edge {
return []ent.Edge{
edge.From("species", Species.Type).
Ref("habitats"),
}
}// schema/species_habitat.go — join table
package schema
import "github.com/blobmasterbrian/fournf"
type SpeciesHabitat struct { ent.Schema }
func (SpeciesHabitat) Annotations() []schema.Annotation {
return []schema.Annotation{
fournf.JoinTable(),
}
}
func (SpeciesHabitat) Fields() []ent.Field {
return []ent.Field{
field.Int("species_id"),
field.Int("habitat_id"),
}
}
func (SpeciesHabitat) Edges() []ent.Edge {
return []ent.Edge{
edge.To("species", Species.Type).
Unique().
Required().
Field("species_id"),
edge.To("habitat", Habitat.Type).
Unique().
Required().
Field("habitat_id"),
}
}Foreign keys on entity tables. If an entity schema places a foreign key
directly on its own table via .Field(), code generation will fail:
// schema/species.go — BAD: foreign key on an entity table
package schema
type Species struct { ent.Schema }
func (Species) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Int("habitat_id"), // foreign key lives on the entity table
}
}
func (Species) Edges() []ent.Edge {
return []ent.Edge{
edge.To("habitat", Habitat.Type).
Unique().
Field("habitat_id"), // this triggers the violation
}
}4NF violation: entity "Species" has edge "habitat" with .Field("habitat_id");
move this foreign key to a join table schema annotated with fournf.JoinTable()
Non-foreign-key fields on join tables. Join tables may only contain foreign key fields. Adding extra columns defeats the purpose of the join table:
// schema/species_habitat.go — BAD: extra field on a join table
package schema
type SpeciesHabitat struct { ent.Schema }
func (SpeciesHabitat) Annotations() []schema.Annotation {
return []schema.Annotation{fournf.JoinTable()}
}
func (SpeciesHabitat) Fields() []ent.Field {
return []ent.Field{
field.Int("species_id"),
field.Int("habitat_id"),
field.String("notes"), // not a foreign key
}
}4NF violation: join table "SpeciesHabitat" has non-foreign-key field "notes";
join tables may only contain foreign key fields
Join tables with fewer than two foreign key edges. A join table must link at least two entities:
// schema/species_habitat.go — BAD: only one foreign key edge
package schema
type SpeciesHabitat struct { ent.Schema }
func (SpeciesHabitat) Annotations() []schema.Annotation {
return []schema.Annotation{fournf.JoinTable()}
}
func (SpeciesHabitat) Fields() []ent.Field {
return []ent.Field{
field.Int("species_id"),
}
}
func (SpeciesHabitat) Edges() []ent.Edge {
return []ent.Edge{
edge.To("species", Species.Type).
Unique().
Required().
Field("species_id"),
}
}4NF violation: join table "SpeciesHabitat" has 1 foreign key edge(s), need at least 2
For a safety net independent of code generation:
func TestFourNF(t *testing.T) {
fournftest.ValidateGraph(t, "./schema", "mymodule/ent")
}In Ent, calling .Field() on an edge places a foreign key column on the schema's table. 4NF requires that multi-valued dependencies are factored into separate tables. This extension enforces that rule at two levels:
- Entity schemas must not have edges with
.Field(). All foreign keys must live in dedicated join table schemas. - Join table schemas (annotated with
fournf.JoinTable()) must have at least two foreign key edges, and every field must be a foreign key. This prevents misuse of the annotation to bypass the entity restriction.
MIT