Hi @Roger_Powell,
I’ve been working on this project for a couple of years. I work with Django web apps for public health; Django is amazing, but Python is slow, so initially I built an API server using Genie.jl.
At that time, AI didn’t exist, and I was looking for syntax similar to the Django ORM; then, with my lack of imagination, I named it as PingoLee ORM form Genie.jl
But I’m facing a lot of gaps in time and knowledge, so with AI, I’m starting another project, Nitro.jl (a fork of Oxygen.jl). Initially, I’m tried to contribute to both (Genie and Oxygen), but I didn’t have much luck.
So, Nitro.jl is a framework to build SPA web packages and API packages. Nitro.jl is getting very fast; PormG.jl is still targeting features, so i haven’t started performance optimizations yet. Both are made to be async.
PormG.jl has a very large collection of features (and a lot more to implement), and it was built to work with AI, including importing models directly from Postgres or Django Python models.
However, neither is registered yet; it’s 0.x with breaking changes between releases. To make those breaking changes cheap, every change ships as a machine-readable upgrade entry (more below).
You can do:
using Pkg
Pkg.add(url="https://github.com/PingoLee/PormG.jl"); Pkg.add("LibPQ")
using PormG, LibPQ
PormG.setup() # db/connection.yml + db/models.jl
PormG.Configuration.load("db")
PormG.Migrations.import_models_from_postgres("db") # reverse-engineer your existing schema
PormG.@import_models "db/models.jl" models
import .models as M
Using it with an AI agent (Claude Code, Copilot, Cursor):
PormG.install_ai_skills() # writes .github/skills/pormg-usage/ into your project
Then ask this for your agent:
Before bumping the PormG dependency, run PormG.upgrade_guide(from = v"<current pin>") and apply what it lists.
Usage examples
Using if:
using PormG, LibPQ, DataFrames
using PormG: Q, Qor
function search_results(; year = nothing, nationality = nothing,
podium_only = false, team = nothing, limit = 20)
query = M.Result.objects
query.values("raceid__year", "raceid__name", "driverid__surname",
"constructorid__name", "positionorder", "points")
if year !== nothing
query.filter("raceid__year" => year)
end
if nationality !== nothing
query.filter("driverid__nationality" => nationality)
end
if podium_only
query.filter("positionorder__@lte" => 3)
end
if team !== nothing
# either the team name or its reference
query.filter(Qor("constructorid__name" => team, "constructorid__constructorref" => team))
end
query.order_by("-raceid__year", "positionorder")
query.limit(limit)
return query |> DataFrame
end
With the same function, you can combine the filters in countless ways:
search_results(nationality = "Brazilian", podium_only = true)
search_results(nationality = "Brazilian", podium_only = true, year = 1988)
search_results(nationality = "Brazilian", team = "McLaren")
search_results(year = 2023, podium_only = true, team = "Red Bull")
search_results(nationality = "British", year = 2020, limit = 5)
search_results() # no filters, latest 20 results
At last:
# pormg_demo.jl — self-contained PormG demo on SQLite.
#
# julia pormg_demo.jl (or, in the REPL: include("pormg_demo.jl"))
#
# First time only:
# using Pkg
# Pkg.add(url = "https://github.com/PingoLee/PormG.jl"); Pkg.add(["SQLite", "DataFrames"])
using PormG, SQLite, DataFrames, Dates
using PormG.Functions: Count, Sum, Avg, Min, Case, When
# ── 1. A throwaway project folder in your home directory ─────────────────────
demo_dir = joinpath(homedir(), "pormg_demo")
rm(demo_dir; recursive = true, force = true) # fresh run every time
mkpath(joinpath(demo_dir, "db"))
cd(demo_dir)
write("db/connection.yml", """
default_env: dev
dev:
adapter: SQLite
database: f1_demo.sqlite
config:
change_db: true
change_data: true
time_zone: 'UTC'
""")
write("db/models.jl", """
module models
import PormG.Models
Circuit = Models.Model(
circuitid = Models.IDField(),
name = Models.CharField(max_length = 100),
country = Models.CharField(max_length = 50),
)
Driver = Models.Model(
driverid = Models.IDField(),
forename = Models.CharField(max_length = 50),
surname = Models.CharField(max_length = 50),
nationality = Models.CharField(max_length = 50),
)
Constructor = Models.Model(
constructorid = Models.IDField(),
constructorref = Models.CharField(max_length = 50),
name = Models.CharField(max_length = 50),
)
Race = Models.Model(
raceid = Models.IDField(),
year = Models.IntegerField(),
name = Models.CharField(max_length = 100),
date = Models.DateField(),
circuitid = Models.ForeignKey(Circuit, on_delete = "CASCADE"),
)
Result = Models.Model(
resultid = Models.IDField(),
raceid = Models.ForeignKey(Race, on_delete = "CASCADE"),
driverid = Models.ForeignKey(Driver, on_delete = "RESTRICT"),
constructorid = Models.ForeignKey(Constructor, on_delete = "RESTRICT"),
grid = Models.IntegerField(),
positionorder = Models.IntegerField(),
points = Models.FloatField(),
)
Models.set_models(@__MODULE__, @__DIR__)
end
""")
# ── 2. Configuration, models, schema ─────────────────────────────────────────
# Absolute path: under include(), a relative "db" would resolve next to this script, not in demo_dir
db = joinpath(demo_dir, "db")
PormG.Configuration.load(db)
include(joinpath(db, "models.jl"))
import .models as M
PormG.Migrations.init_migrations(db)
PormG.Migrations.makemigrations(db; interactive = false)
PormG.Migrations.migrate(db; interactive = false)
# ── 3. Seed a small slice of real F1 history ─────────────────────────────────
bulk_insert(M.Circuit.objects, DataFrame(
circuitid = [1, 2, 3],
name = ["Autódromo José Carlos Pace", "Circuit de Monaco", "Suzuka Circuit"],
country = ["Brazil", "Monaco", "Japan"],
))
bulk_insert(M.Driver.objects, DataFrame(
driverid = [1, 2, 3, 4, 5],
forename = ["Ayrton", "Nelson", "Alain", "Nigel", "Gerhard"],
surname = ["Senna", "Piquet", "Prost", "Mansell", "Berger"],
nationality = ["Brazilian", "Brazilian", "French", "British", "Austrian"],
))
bulk_insert(M.Constructor.objects, DataFrame(
constructorid = [1, 2, 3, 4],
constructorref = ["mclaren", "williams", "ferrari", "benetton"],
name = ["McLaren", "Williams", "Ferrari", "Benetton"],
))
bulk_insert(M.Race.objects, DataFrame(
raceid = [1, 2, 3, 4],
year = [1988, 1988, 1990, 1991],
name = ["Monaco Grand Prix", "Japanese Grand Prix", "Japanese Grand Prix", "Brazilian Grand Prix"],
date = [Date(1988, 5, 15), Date(1988, 10, 30), Date(1990, 10, 21), Date(1991, 3, 24)],
circuitid = [2, 3, 3, 1],
))
bulk_insert(M.Result.objects, DataFrame(
resultid = 1:12,
raceid = [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4],
driverid = [3, 5, 1, 1, 3, 4, 2, 4, 1, 1, 4, 2],
constructorid = [1, 3, 1, 1, 1, 2, 4, 3, 1, 1, 2, 4],
grid = [2, 3, 1, 1, 2, 5, 6, 3, 1, 1, 3, 7],
positionorder = [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3],
points = [9.0, 6.0, 4.0, 9.0, 6.0, 4.0, 9.0, 6.0, 4.0, 10.0, 6.0, 4.0],
))
# ── 4. One function, many queries ────────────────────────────────────────────
"""
search_results(; columns, group_by, metrics, year, nationality, podium_only, team,
min_wins, order, limit, show_sql)
Plain mode: `columns` picks what each result row shows.
Aggregate mode: pass `group_by` and every result collapses into one row per group, with the
`metrics` you ask for. The `GROUP BY` is built by PormG from the non-aggregate columns.
group_by e.g. ["driverid__surname"]
year 1988 or 1988:1990
nationality "Brazilian" or ["Brazilian", "French"]
team name ("McLaren") or reference ("mclaren")
min_wins needs :wins in metrics; becomes HAVING
"""
function search_results(; columns = ["raceid__year", "raceid__name", "driverid__surname", "constructorid__name", "positionorder", "points"],
group_by = nothing, metrics = [:races, :wins, :points],
year = nothing, nationality = nothing, podium_only = false, team = nothing,
min_wins = nothing, order = nothing, limit = 20, show_sql = true)
query = M.Result.objects
# ── what to show ──
if group_by === nothing
query.values(columns...)
else
aggregates = Dict(
:races => "races" => Count("resultid"),
:wins => "wins" => Sum(Case(When("positionorder" => 1, then = 1), default = 0)),
:podiums => "podiums" => Sum(Case(When("positionorder__@lte" => 3, then = 1), default = 0)),
:points => "points" => Sum("points"),
:avg_points => "avg_points" => Avg("points"),
:best_grid => "best_grid" => Min("grid"),
)
query.values(group_by..., (aggregates[m] for m in metrics)...)
end
# ── which rows ──
if year isa AbstractRange
query.filter("raceid__year__@gte" => first(year), "raceid__year__@lte" => last(year))
elseif year !== nothing
query.filter("raceid__year" => year)
end
if nationality isa AbstractVector
query.filter("driverid__nationality__@in" => nationality)
elseif nationality !== nothing
query.filter("driverid__nationality" => nationality)
end
if podium_only
query.filter("positionorder__@lte" => 3)
end
if team !== nothing
query.filter(Qor("constructorid__name" => team, "constructorid__constructorref" => team))
end
if min_wins !== nothing && group_by !== nothing
query.filter("wins__@gte" => min_wins) # filter on an aggregate → HAVING
end
# ── how to sort ──
if order !== nothing
query.order_by(order...)
elseif group_by === nothing
query.order_by("-raceid__year", "positionorder")
else
query.order_by("-" * first(aggregates[m] for m in metrics).first)
end
query.limit(limit)
if show_sql # inspection never runs the query
info = inspect_query(query)
println(info[:sql_text])
println("-- parameters: ", info[:parameters])
end
return query |> DataFrame
end
# Prints the title, the SQL, then the rows
function demo(title; kwargs...)
println("\n══ ", title)
println(search_results(; kwargs...))
end
demo("Brazilian podiums";
nationality = "Brazilian", podium_only = true)
demo("Only three columns, 1988";
columns = ["driverid__surname", "raceid__name", "points"], year = 1988)
demo("McLaren, by reference, 1988–1990";
team = "mclaren", year = 1988:1990)
demo("Per driver: races, wins, points";
group_by = ["driverid__surname"])
demo("Per driver, only winners, sorted by wins";
group_by = ["driverid__surname"], metrics = [:wins, :podiums, :points],
min_wins = 1, order = ["-wins", "driverid__surname"])
demo("Per team and season";
group_by = ["constructorid__name", "raceid__year"],
metrics = [:points, :avg_points], order = ["raceid__year", "-points"])
demo("Per nationality, Brazil vs France";
group_by = ["driverid__nationality"], nationality = ["Brazilian", "French"],
metrics = [:podiums, :wins])
demo("Per circuit country";
group_by = ["raceid__circuitid__country"], metrics = [:races, :points])