Lumo ORM is a lightweight, Active Record-style ORM for Lua, designed to work with SQLite. It provides an intuitive API for database interactions, including querying, relationships, and migrations.
- Active Record-style models with intuitive API
- Advanced Query Builder with chainable methods
- Transaction support with automatic rollback
- Collections with functional programming methods (map, filter, reduce, etc.)
- Migrations system with CLI support
- Database seeding with fake data generators
- LuaRocks-compatible installation
- SQLite support via
lsqlite3complete
- Complex WHERE conditions (AND, OR, IN, NOT, NULL checks)
- JOINs (INNER, LEFT, RIGHT)
- Aggregations (COUNT, SUM, AVG, MIN, MAX)
- GROUP BY and HAVING clauses
- DISTINCT queries
- Pagination with metadata
- Chunked processing for large datasets
- Raw SQL conditions
- Bulk insert optimization
- Auto timestamps (created_at, updated_at)
- Soft deletes with restore capability
- Query scopes for reusable filters
- Attribute casting (integer, boolean, string, json, datetime)
- Mass assignment protection (fillable/guarded)
- Model events/hooks (before/after create, save, update, delete)
- Validation system with built-in rules
- One-to-One (hasOne, belongsTo)
- One-to-Many (hasMany)
- Many-to-Many (belongsToMany)
- Has Many Through (indirect relationships)
- Polymorphic relationships (morphMany, morphOne, morphTo)
- Automatic cascade delete support
- Eager loading to reduce N+1 queries
You can install Lumo ORM via LuaRocks:
luarocks install lua-lumo-ormOr clone the repository manually:
git clone https://github.com/bhhaskin/lua-lumo-orm.git
cd lua-lumo-orm
luarocks makelocalLumo=require("lumo")
Lumo.connect("database.sqlite")localModel=require("lumo.model")
localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"returnUserlocalUser=require("models.user")
-- Basic querieslocalusers=User:all() -- Returns a Collectionlocaluser=User:find(1)
localfirst=User:first()
-- WHERE conditionslocalactiveUsers=User:where("status", "=", "active")
:where("age", ">", 18)
:orderBy("name", "ASC")
:get()
-- OR conditionslocaladmins=User:where("role", "=", "admin")
:orWhere("role", "=", "moderator")
:get()
-- IN / NOT IN querieslocalusers=User:whereIn("id", {1, 2, 3, 4, 5}):get()
-- NULL checkslocalverified=User:whereNotNull("email_verified_at"):get()
localunverified=User:whereNull("email_verified_at"):get()
-- NOT conditionslocalnotBanned=User:whereNot("status", "=", "banned"):get()
-- Raw SQL conditionslocalusers=User:whereRaw("age BETWEEN ? AND ?", 18, 65):get()
-- Select specific columnslocalnames=User:select("id", "name", "email"):get()
-- Distinct resultslocalcountries=User:select("country"):distinct():get()
-- Working with Collectionsfori, userinipairs(users) doprint(user.name)
end-- Collection methodslocalnames=users:map(function(u) returnu.nameend)
localadults=users:filter(function(u) returnu.age>=18end)
localsorted=users:sortBy("name")
localcount=users:count()localnewUser=User:create({ name="Alice", email="alice@example.com" })
print("Created user:", newUser.id)user:update({ name="Alice Wonderland" })user:delete()-- Define a User model with posts relationshiplocalUser=setmetatable({}, Model)
User.__index=UserUser.table="users"functionUser:posts()
returnself:hasMany(Post, "user_id")
end-- Define a Post model with user relationshiplocalPost=setmetatable({}, Model)
Post.__index=PostPost.table="posts"functionPost:user()
returnself:belongsTo(User, "user_id")
end-- Use relationships (returns Model instances)localuser=User:find(1)
localposts=user:posts() -- Returns Collection of Post modelsfori, postinipairs(posts) doprint(post.title)
post:update({ title="Updated Title" })
end-- Belongs to relationshiplocalpost=Post:find(1)
localauthor=post:user() -- Returns User model instanceprint(author.name)-- Define cascade behaviorUser.__cascadeDelete= { "posts" }
-- When user is deleted, all posts are automatically deletedlocaluser=User:find(1)
user:delete() -- Automatically deletes all user's postslocalLumo=require("lumo")
Lumo.connect("database.sqlite")
-- Automatic transaction with rollback on errorLumo.db:transaction(function()
localuser=User:create({ name="Alice" })
Post:create({ title="First Post", user_id=user.id })
Post:create({ title="Second Post", user_id=user.id })
-- If any operation fails, all changes are rolled backend)
-- Manual transaction controlLumo.db:beginTransaction()
localuser=User:create({ name="Bob" })
Lumo.db:commit()
-- Or Lumo.db:rollback() to undo changes-- Count recordslocaltotal=User:count()
localactiveCount=User:where("status", "=", "active"):count()
-- Sum, Average, Min, MaxlocaltotalViews=Post:sum("views")
localavgAge=User:avg("age")
localyoungest=User:min("age")
localoldest=User:max("age")-- Inner joinlocalresults=User:query()
:join("posts", "users.id", "=", "posts.user_id")
:where("posts.published", "=", true)
:get()
-- Left joinlocalresults=User:query()
:leftJoin("posts", "users.id", "=", "posts.user_id")
:get()-- Group by with havinglocalresults=Post:query()
:select("user_id", "COUNT(*) as post_count")
:groupBy("user_id")
:having("post_count", ">", 5)
:get()-- Get page 2 with 15 items per pagelocalusers=User:forPage(2, 15):get()
-- Paginate with metadatalocalpaginated=User:query():paginate(15, 1)
print(paginated.total) -- Total recordsprint(paginated.current_page) -- Current pageprint(paginated.last_page) -- Total pagesfor_, userinipairs(paginated.data) doprint(user.name)
end-- Process 100 records at a timeUser:query():chunk(100, function(users, page)
print("Processing page " ..page)
for_, userinipairs(users) do-- Process userendend)-- Insert many records at onceUser:query():insertMany({
{ name="Alice", email="alice@example.com" },
{ name="Bob", email="bob@example.com" },
{ name="Charlie", email="charlie@example.com" }
})localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"User.timestamps=true-- Enable auto timestamps-- When you create or update, created_at and updated_at are automaticlocaluser=User:create({ name="Alice" })
print(user.created_at, user.updated_at)
user:update({ name="Alice Updated" })
print(user.updated_at) -- Automatically updatedlocalUser=setmetatable({}, Model)
User.__index=UserUser.table="users"User.softDelete=true-- Enable soft deletes-- Soft delete (sets deleted_at timestamp)localuser=User:find(1)
user:delete()
-- Query excludes soft deleted by defaultlocalusers=User:all() -- Won't include deleted users-- Include soft deleted recordslocalallUsers=User:withTrashed():all()
-- Only soft deleted recordslocaldeleted=User:onlyTrashed():all()
-- Restore soft deleted recorduser:restore()
-- Permanently deleteuser:forceDelete()localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"User.casts= {
age="integer",
is_admin="boolean",
salary="number",
settings="json",
created_at="datetime"
}
-- Values are automatically castlocaluser=User:find(1)
print(type(user.age)) -- numberprint(type(user.is_admin)) -- booleanlocalUser=setmetatable({}, Model)
User.__index=UserUser.table="users"-- Define a scopefunctionUser:scopeActive(query)
returnquery:where("status", "=", "active")
endfunctionUser:scopeAdult(query)
returnquery:where("age", ">=", 18)
end-- Use scopeslocalactiveUsers=User:active():get()
localactiveAdults=User:active():adult():get()localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"User.fillable= { "name", "email" } -- Only these can be mass-assigned-- Or use guarded to blacklist fields-- User.guarded = { "is_admin", "role" }localuser=User:new()
user:fillAttributes({
name="Alice",
email="alice@example.com",
is_admin=true-- This will be ignored
})localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"functionUser:beforeCreate()
print("About to create user")
returntrue-- Return false to cancelendfunctionUser:afterCreate()
print("User created!")
-- Send welcome email, etc.endfunctionUser:beforeSave()
-- Hash password, etc.returntrueend-- Available hooks:-- beforeCreate, afterCreate-- beforeSave, afterSave-- beforeUpdate, afterUpdate-- beforeDelete, afterDeletelocalUser=setmetatable({}, Model)
User.__index=UserUser.table="users"User.rules= {
name="required|min:3|max:255",
email="required|email|unique:users",
age="numeric|min:18"
}
-- Validation runs automatically on createlocaluser=User:create({
name="Al", -- Too shortemail="invalid-email"
})
-- Error: Validation failed: name must be at least 3 characters, email must be a valid email address-- Manual validationlocalvalid, errors=User:validate(data)
ifnotvalidthenprint(table.concat(errors, ", "))
end-- Country -> User -> PostlocalCountry=setmetatable({}, Model)
Country.__index=CountryCountry.table="countries"functionCountry:posts()
returnself:hasManyThrough(Post, User, "country_id", "user_id")
endlocalcountry=Country:find(1)
localposts=country:posts() -- All posts from users in this country-- Comments can belong to either Posts or VideoslocalComment=setmetatable({}, Model)
Comment.__index=CommentComment.table="comments"functionComment:commentable()
returnself:morphTo("commentable")
end-- Post has many comments (polymorphic)localPost=setmetatable({}, Model)
Post.__index=PostPost.table="posts"functionPost:comments()
returnself:morphMany(Comment, "commentable")
endlocalpost=Post:find(1)
localcomments=post:comments() -- All comments for this postlocalSeeder=require("lumo.seeder")
-- Register a seederSeeder.register("UserSeeder", function()
localUser=require("models.user")
fori=1, 10doUser:create({
name=Seeder.fake.name(),
email=Seeder.fake.email(),
age=Seeder.fake.number(18, 65),
country=Seeder.fake.choice({"USA", "UK", "Canada"})
})
endend)
-- Run seedersSeeder:run() -- Run allSeeder:runSeeder("UserSeeder") -- Run specific oneTo apply migrations:
lua bin/migrate.lua upTo rollback:
lua bin/migrate.lua downLumo ORM includes a test suite using busted. You can run tests manually with:
docker build -f Dockerfile.dev -t lumo-orm-test .
docker run --rm lumo-orm-testInstead of manually building and running the Docker container, you can use the provided Makefile for convenience.
make buildThis will build the Docker image using Dockerfile.dev.
make testThis will build the image (if not already built) and run the test suite inside a temporary container.
make shellThis will open an interactive shell inside the Docker container for debugging.
make cleanRemoves the built Docker image to free up space.
Pull requests are welcome! Please follow the project structure and ensure tests pass before submitting.
This project is licensed under the MIT License.