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.
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
Edit Your Models File
- By default, models are defined in
db/models.jl. - Each model is a Julia struct using PormG field types.
- By default, models are defined in
Example Model Definition
Driver = Models.Model(
id = Models.IDField(),
name = Models.CharField(max_length=100),
birthdate = Models.DateField(),
nationality = Models.CharField(max_length=50)
)- 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 (honoringdb_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 explicitnamein 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");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 (honoringdb_column). The order matters: an index over("raceid", "lap")serves a lookup byraceid, or byraceidandlaptogether, but not one bylapalone.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 aNOTICE) — pass an explicitnamein 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");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.
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:
UsernotUsers,ProductnotProducts - Be descriptive and clear:
User_profile,Product_category,Order_history
Model Organization
- Keep models in
db/models.jlor 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)
- Define your models in a
models.jlfile. - Use
PormG.Migrations.makemigrations()to detect changes. - 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
endWhat @import_models Does
- Resolves the model file path relative to your source file
- Tracks the file with Revise (if available) for hot-reloading in interactive sessions
- Registers models with PormG so fields and metadata are indexed
- Injects
__init__()to re-register models after package precompilation - 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,ForeignKeyandOneToOneFieldalso acceptprimary_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.