Defining Models in PormG

PormG models describe the structure of your database tables using Julia code, inspired by Django ORM but tailored for Julia's syntax and performance.

What is a Model?

A model is a Julia object that defines the fields (columns) and their types for a database table. Each model maps directly to a table in your PostgreSQL or SQLite database.

How model and field names become a schema

The rules that turn a model into table/column names — table naming, foreign-key columns, primary keys, default on_delete, and identifier quoting — are a frozen contract documented in Schema Conventions.

Creating a Model

  1. Edit Your Models File

    • By default, models are defined in db/models.jl.
    • Each model is a Julia struct using PormG field types.
  2. Example Model Definition

Driver = Models.Model(
    id = Models.IDField(),
    name = Models.CharField(max_length=100),
    birthdate = Models.DateField(),
    nationality = Models.CharField(max_length=50)
)
  1. Example of module construction in db/models.jl
module models
import PormG.Models
import PormG.Models: RESTRICT, CASCADE, SET_NULL, SET_DEFAULT, DO_NOTHING


Status = Models.Model(
  statusid = Models.IDField(),
  status = Models.CharField()
)

Circuit = Models.Model( # You can create a model like a Django model for each table so that you can define a huge number of tables at once in just one file. Please capitalize the Julia BINDING (`Circuit`) — it is lowercased to derive the table name. A positional table name, when you give one, must itself be lowercase: `Models.Model("Circuit", …)` raises ModelDefinitionError.
  circuitid = Models.IDField(), # House style: declare field names in lowercase snake_case. PormG preserves the case you declare (so mixed-case/uppercase legacy columns are supported) and field lookups are case-sensitive — query fields in the same case you declared them.
  circuitref = Models.CharField(),
  name = Models.CharField(),
  location = Models.CharField(),
  country = Models.CharField(),
  lat = Models.FloatField(),
  lng = Models.FloatField(),
  alt = Models.IntegerField(),
  url = Models.CharField()
)

Race = Models.Model(
  raceid = Models.IDField(),
  year = Models.IntegerField(),
  round = Models.IntegerField(),
  circuitid = Models.ForeignKey(Circuit, pk_field="circuitid", on_delete="CASCADE"),
  name = Models.CharField(),
  date = Models.DateField(),
  time = Models.TimeField(null=true),
  url = Models.CharField(),
  fp1_date = Models.DateField(null=true),
  fp1_time = Models.TimeField(null=true),
  fp2_date = Models.DateField(null=true),
  fp2_time = Models.TimeField(null=true),
  fp3_date = Models.DateField(null=true),
  fp3_time = Models.TimeField(null=true),
  quali_date = Models.DateField(null=true),
  quali_time = Models.TimeField(null=true),
  sprint_date = Models.DateField(null=true),
  sprint_time = Models.TimeField(null=true),
)

Driver = Models.Model(
  driverid = Models.IDField(),
  driverref = Models.CharField(),
  number = Models.IntegerField(null=true),
  code = Models.CharField(),
  forename = Models.CharField(),
  surname = Models.CharField(),
  dob = Models.DateField(),
  nationality = Models.CharField(),
  url = Models.CharField()
)

Constructor = Models.Model(
  constructorid = Models.IDField(),
  constructorref = Models.CharField(),
  name = Models.CharField(),
  nationality = Models.CharField(),
  url = Models.CharField()
)

Result = Models.Model(
  resultid = Models.IDField(),
  raceid = Models.ForeignKey(Race, pk_field="raceid", on_delete="CASCADE"),
  driverid = Models.ForeignKey(Driver, pk_field="driverid", on_delete="RESTRICT"),
  constructorid = Models.ForeignKey(Constructor, pk_field="constructorid", on_delete="RESTRICT"),
  number = Models.IntegerField(null=true),
  grid = Models.IntegerField(),
  position = Models.IntegerField(null=true),
  positiontext = Models.CharField(),
  positionorder = Models.IntegerField(),
  points = Models.FloatField(),
  laps = Models.IntegerField(),
  time = Models.CharField(null=true),
  milliseconds = Models.IntegerField(null=true),
  fastestlap = Models.IntegerField(null=true),
  rank = Models.IntegerField(null=true),
  fastestlaptime = Models.DurationField(null=true),
  fastestlapspeed = Models.FloatField(null=true),
  statusid = Models.ForeignKey(Status, pk_field="statusid", on_delete="CASCADE")
)

Just_a_test_deletion = Models.Model(
  id = Models.IDField(),
  name = Models.CharField(),
  test_result = Models.ForeignKey(Result, pk_field="resultid", on_delete="CASCADE", null=true, related_name="test_deletion"),
  test_result2 = Models.ForeignKey(Result, pk_field="resultid", on_delete="CASCADE", null=true, related_name="test_deletion2")
)

end
  • Each field uses a PormG field constructor (e.g., IDField, CharField, DateField).
  • You can use keyword arguments to customize field options (e.g., max_length, unique, null).

Composite Uniqueness (unique_together)

A single-column uniqueness rule is a field option (unique=true). To require a combination of two or more columns to be unique together — Django's Meta.unique_together — declare a model-level constraints=[...] list of Models.UniqueConstraint objects (the same shape as Django 2.2+ / SQLAlchemy named constraints):

Constructor_engine = Models.Model("constructor_engines",
  id = Models.IDField(),
  constructorid = Models.ForeignKey(Constructor, pk_field="constructorid", on_delete="CASCADE"),
  year = Models.IntegerField(),
  engine_manufacturer = Models.CharField(max_length=50),
  constraints = [
    Models.UniqueConstraint(fields=("constructorid", "year"), name="uniq_constructor_year"),
  ],
)

Each UniqueConstraint takes:

  • fields — a tuple (or vector) of field names on this model. Foreign-key fields are referenced by the field name; PormG resolves each to its physical column (honoring db_column).
  • name — the index name (optional). When omitted, PormG derives <table>_<cols>_uniq, matching the auto-generated many-to-many index convention. On tables/columns with long names the derived name can exceed PostgreSQL's 63-byte identifier limit (which Postgres silently truncates) — pass an explicit name in that case to keep it stable and unique.

A model may carry several constraints (each its own tuple). At migration time each becomes a CREATE UNIQUE INDEX — identical on PostgreSQL and SQLite:

CREATE UNIQUE INDEX IF NOT EXISTS "uniq_constructor_year"
  ON "constructor_engines" ("constructorid", "year");
Materialized when the table is created

In this release a UniqueConstraint is emitted when its table is first created (the same lifecycle as the automatic many-to-many join-table index). Adding or removing a constraint on a table that already exists is not yet detected by makemigrations — it requires composite-index introspection that is tracked as a follow-up. Declare composite uniqueness when you create the model, or add the index by hand on an existing table.

Composite Indexes (Meta.indexes)

A single-column index is a field option (db_index=true). To index a combination of two or more columns — Django's Meta.indexes — declare a model-level indexes=[...] list of Models.Index objects:

Lap_times = Models.Model("lap_times",
  raceid   = Models.ForeignKey(Race, pk_field="raceid", on_delete="CASCADE"),
  driverid = Models.ForeignKey(Driver, pk_field="driverid", on_delete="RESTRICT"),
  lap      = Models.IntegerField(),
  position = Models.IntegerField(),
  indexes = [
    Models.Index(fields=("raceid", "lap"), name="lap_times_race_lap_idx"),
  ],
)

Each Index takes:

  • fields — a tuple (or vector) of two or more field names on this model. Foreign-key fields are referenced by the field name; PormG resolves each to its physical column (honoring db_column). The order matters: an index over ("raceid", "lap") serves a lookup by raceid, or by raceid and lap together, but not one by lap alone.
  • name — the index name (optional). When omitted, PormG derives <table>_<cols>_idx, the plain sibling of the composite-unique convention. On long table/column names the derived name can exceed PostgreSQL's 63-byte identifier limit (which Postgres truncates, with only a NOTICE) — pass an explicit name in that case.

An Index speeds up reads and constrains nothing. For a composite uniqueness guarantee, use Composite Uniqueness instead — that is a CREATE UNIQUE INDEX and rejects duplicate rows. Each Index becomes a plain CREATE INDEX, identical on PostgreSQL and SQLite:

CREATE INDEX IF NOT EXISTS "lap_times_race_lap_idx"
  ON "lap_times" ("raceid", "lap");
One column is `db_index`, not a one-field `Index`

Models.Index(fields=("lap",)) raises ModelDefinitionError. A one-column CREATE INDEX is byte-identical whether db_index=true or an Index emitted it, and introspection has no way to tell them apart — so a one-field Index would read back as db_index, never match its own declaration, and make makemigrations propose dropping the index on every run. Declare db_index=true on the field instead.

Created with the table; read back, but not yet diffed

An Index is emitted when its table is first created, the same lifecycle as UniqueConstraint. Introspection does read composite indexes back on both backends, so inspectdb on an existing database reproduces them and a re-run of makemigrations proposes nothing. What is not yet detected is adding or removing an Index on a table that already exists — declare it with the model, or add the index by hand.

Naming Conventions and Considerations

Model Naming Rules

  • Use snake_case with capitalized first letter: User, Product, Order_item
  • Use singular nouns: User not Users, Product not Products
  • Be descriptive and clear: User_profile, Product_category, Order_history

Model Organization

  • Keep models in db/models.jl or similar organized structure
  • Group related models together in logical sections
  • Use meaningful comments to explain complex relationships

Development Workflow

PormG supports two primary workflows for model creation:

1. Model-First (Recommended for New Projects)

  1. Define your models in a models.jl file.
  2. Use PormG.Migrations.makemigrations() to detect changes.
  3. Use PormG.Migrations.migrate() to apply them to your database.

2. DB-First (Legacy or Existing Databases)

If you already have a database, you can use the PormG.setup() utility to generate your model code automatically:

using PormG
# This will introspect the DB and create a basic models.jl for you
PormG.setup("path/to/my/db") 

Loading Models in Your Application

Using @import_models (Recommended)

The @import_models macro is the recommended way to load models in your application:

# In your main module (e.g., mypkg.jl):
module MyApp
    using PormG
    
    # Load models from external file with hot-reload support
    PormG.@import_models "db/models.jl" my_models
    import .my_models as M
    
    # Now use M.Driver, M.Race, M.Result, etc.
    # Models automatically update when you edit db/models.jl and save
end

What @import_models Does

  1. Resolves the model file path relative to your source file
  2. Tracks the file with Revise (if available) for hot-reloading in interactive sessions
  3. Registers models with PormG so fields and metadata are indexed
  4. Injects __init__() to re-register models after package precompilation
  5. Enables hot-reloading: Edit your models.jl, save, and model changes appear instantly in the REPL

Inline Models (without a separate file)

If you define models directly in code rather than a separate file, use the @models_module macro:

PormG.@models_module my_models "db" begin
    import PormG.Models as M

    Driver = M.Model("drivers",
        driverid = M.IDField(),
        forename = M.CharField()
    )
end
import .my_models as M

@models_module handles registration automatically — no manual set_models() call is needed.

Hot-Reloading Model Definitions

When using @import_models with Revise.jl, model changes are automatically detected and applied:

# Your REPL session (with Revise.jl loaded):
julia> using MyApp
julia> M.Driver.fields  # Shows current fields: id, name

# Edit db/models.jl to add a field, save the file...

julia> M.Driver.fields  # Automatically updated with new field!

This enables rapid development and testing without restarting Julia. When you modify models:

  • Add new fields
  • Remove fields
  • Change field types
  • Adjust field parameters

All changes are automatically reloaded and available in the next REPL command.

Supported Field Types

PormG provides comprehensive field types for all common database scenarios:

  • Primary Key Fields: IDField (CharField, UUIDField, ForeignKey and OneToOneField also accept primary_key=true)
  • Text Fields: CharField, TextField, EmailField
  • Numeric Fields: IntegerField, BigIntegerField, FloatField, DecimalField
  • Date/Time Fields: DateField, DateTimeField, TimeField, DurationField
  • Other Types: BooleanField, ImageField, BinaryField
  • Relationship Fields: ForeignKey, OneToOneField

For detailed documentation on each field type, including parameters, examples, and best practices, see Field Types Reference.

Post-Precompilation Behavior

When your package is precompiled (e.g., after import MyPkg), the @import_models macro ensures models are automatically re-registered via injected __init__() functions. This means:

  • Models are available in package code without manual registration
  • Hot-reloading continues to work in interactive sessions
  • No additional setup is required for REPL users

For more details, see the PormG Documentation or the example scripts in the test/integration/ folder.