Skip to content

Relations ​

Describe how related rows load — relations are declared in the schema and create no SQL column of their own.

Declaring relations ​

Relation types go right in the schema alongside your columns. They describe how to load related rows, not how to store data.

lua
local User = db:define("users", {
    id      = Norm.types.id(),
    posts   = Norm.types.hasMany("posts", { key = "user_id" }),
    profile = Norm.types.hasOne("profiles", { key = "user_id" }),
    roles   = Norm.types.belongsToMany("roles"),          -- through pivot `role_user`
})
local Post = db:define("posts", {
    id      = Norm.types.id(),
    user_id = Norm.types.integer(),
    author  = Norm.types.belongsTo("users", { key = "user_id", onDelete = "CASCADE" }),
})
  • hasMany(table, opts) — one-to-many.
  • hasOne(table, opts) — one-to-one.
  • belongsTo(table, opts) — inverse side; owns the foreign key.
  • belongsToMany(table, opts) — many-to-many through a pivot table (e.g. role_user).

Lazy loading ​

record:load(name) runs one query and caches the result on record[name].

lua
local user = User:find(1):await()
user:load("posts"):await()
print(#user.posts)

Eager loading with include ​

query:include(...) runs one batched query per relation level — no N+1. Nest with a dotted path, and pass a per-relation configurator to filter, order, limit, or nest further.

lua
User:query():include("posts.comments"):all():await()           -- nested
User:query():include("posts", function(q)                      -- with options
    q:where("published", true):order("created_at", "DESC"):limit(5)
     :include("comments", function(c) c:order("created_at") end)
end):all():await()

TIP

Use include to load relations. To filter or sort by a related column instead, use a join — see Query builder.

Filtering & counting by relation ​

These use correlated subqueries — no join is added.

lua
User:where_has("posts", function(q) q:where("published", true) end):all():await()
User:where_doesnt_have("posts"):all():await()
User:with_count("posts"):all():await()                         -- users[i].posts_count
  • where_has(name, fn?) — keep rows that have at least one matching related row.
  • where_doesnt_have(name, fn?) — keep rows with no matching related row.
  • with_count(name) — adds a <name>_count field to each record.

Many-to-many mutation ​

lua
record:attach(name, ids, pivot?)    -- add pivot rows (optional extra pivot columns)
record:detach(name, ids?)           -- remove pivot rows (all if ids omitted)
record:sync_pivot(name, ids)        -- make the pivot exactly match ids

Foreign keys ​

sync() emits real FOREIGN KEY constraints from belongsTo relations (honoring onDelete / onUpdate), and creates tables in dependency order.

This is controlled by the foreignKeys option on Norm.new:

  • "auto" (default) — emits on MySQL, skips on SQLite with a one-time warning.
  • true — always emit.
  • false — never emit.

WARNING

SQLite only enforces foreign keys with PRAGMA foreign_keys = ON (per-connection), which Norm can't guarantee across a connection pool. That's why "auto" skips them on SQLite.

Cascading deletes come from the onDelete = "CASCADE" option on a belongsTo relation, as shown above on Post.author.