Skip to content

Repository files navigation

Lumo ORM

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.

Features

Core Features

  • 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

Query Features

  • 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

Model Features

  • 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

Relationships

  • 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

Installation

You can install Lumo ORM via LuaRocks:

luarocks install lua-lumo-orm

Or clone the repository manually:

git clone https://github.com/bhhaskin/lua-lumo-orm.git
cd lua-lumo-orm
luarocks make

Usage

Connecting to a Database

localLumo=require("lumo")
Lumo.connect("database.sqlite")

Defining a Model

localModel=require("lumo.model")
localUser=setmetatable({}, Model)
User.__index=UserUser.table="users"returnUser

Querying Data

localUser=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()

Creating a Record

localnewUser=User:create({ name="Alice", email="alice@example.com" })
print("Created user:", newUser.id)

Updating a Record

user:update({ name="Alice Wonderland" })

Deleting a Record

user:delete()

Working with Relationships

-- 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)

Cascade Delete

-- Define cascade behaviorUser.__cascadeDelete= { "posts" }
-- When user is deleted, all posts are automatically deletedlocaluser=User:find(1)
user:delete() -- Automatically deletes all user's posts

Using Transactions

localLumo=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

Advanced Query Features

Aggregations

-- 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")

JOINs

-- 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 and HAVING

-- Group by with havinglocalresults=Post:query()
:select("user_id", "COUNT(*) as post_count")
:groupBy("user_id")
:having("post_count", ">", 5)
:get()

Pagination

-- 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

Chunking Large Datasets

-- Process 100 records at a timeUser:query():chunk(100, function(users, page)
print("Processing page " ..page)
for_, userinipairs(users) do-- Process userendend)

Bulk Operations

-- 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" }
})

Model Features

Auto Timestamps

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 updated

Soft Deletes

localUser=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()

Attribute Casting

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)) -- boolean

Query Scopes

localUser=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()

Mass Assignment Protection

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
})

Model Events/Hooks

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, afterDelete

Validation

localUser=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

Advanced Relationships

Has Many Through

-- 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

Polymorphic Relationships

-- 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 post

Database Seeding

localSeeder=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 one

Running Migrations

To apply migrations:

lua bin/migrate.lua up

To rollback:

lua bin/migrate.lua down

Running Tests

Lumo 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-test

Using Makefile for Automation

Instead of manually building and running the Docker container, you can use the provided Makefile for convenience.

Build the Docker Image

make build

This will build the Docker image using Dockerfile.dev.

Run Tests

make test

This will build the image (if not already built) and run the test suite inside a temporary container.

Open a Shell in the Container

make shell

This will open an interactive shell inside the Docker container for debugging.

Clean Up Docker Images

make clean

Removes the built Docker image to free up space.

Contributing

Pull requests are welcome! Please follow the project structure and ensure tests pass before submitting.

License

This project is licensed under the MIT License.

About

A lightweight Active Record ORM for Lua with SQLite support

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages