From d589d439b5c1e6cef557c4a42d5ae3debf8b8a28 Mon Sep 17 00:00:00 2001 From: Bryan Haskin Date: Thu, 27 Feb 2025 13:56:06 -0800 Subject: [PATCH 1/4] check point --- .gitignore | 1 + Dockerfile.dev | 15 ++++ README.md | 137 +++++++++++++++++++++++++++++- bin/migrate.lua | 20 +++++ bin/seed.lua | 8 ++ makefile | 26 ++++++ rockspecs/lumo-orm-0.1-0.rockspec | 31 +++++++ spec/db_spec.lua | 63 ++++++++++++++ spec/model_spec.lua | 69 +++++++++++++++ spec/query_builder_spec.lua | 61 +++++++++++++ spec/relationships_spec.lua | 82 ++++++++++++++++++ src/lumo.lua | 22 +++++ src/lumo/db.lua | 52 ++++++++++++ src/lumo/migrations.lua | 92 ++++++++++++++++++++ src/lumo/model.lua | 95 +++++++++++++++++++++ src/lumo/query_builder.lua | 102 ++++++++++++++++++++++ src/lumo/relationships.lua | 88 +++++++++++++++++++ 17 files changed, 962 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 Dockerfile.dev create mode 100644 bin/migrate.lua create mode 100644 bin/seed.lua create mode 100644 makefile create mode 100644 rockspecs/lumo-orm-0.1-0.rockspec create mode 100644 spec/db_spec.lua create mode 100644 spec/model_spec.lua create mode 100644 spec/query_builder_spec.lua create mode 100644 spec/relationships_spec.lua create mode 100644 src/lumo.lua create mode 100644 src/lumo/db.lua create mode 100644 src/lumo/migrations.lua create mode 100644 src/lumo/model.lua create mode 100644 src/lumo/query_builder.lua create mode 100644 src/lumo/relationships.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bd6961 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.src.rock \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..f5b17c3 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM openresty/openresty:alpine-fat AS builder + +RUN apk add --no-cache \ + sqlite + +RUN luarocks install busted && \ + luarocks install lsqlite3complete + +WORKDIR /app + +COPY . /app + +ENV LUA_PATH="/app/src/?.lua;/app/src/?/init.lua;/usr/local/openresty/lualib/?.lua;;" + +CMD ["busted", "spec"] \ No newline at end of file diff --git a/README.md b/README.md index fc71e91..59c9b8e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ -# lua-lumo -Lua ORM for sqlite3 +# 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 +- Active Record-style models +- Query Builder with chainable methods +- One-to-One, One-to-Many, and Many-to-Many relationships +- Migrations system with CLI support +- LuaRocks-compatible installation +- SQLite support via `lsqlite3complete` + +## Installation + +You can install Lumo ORM via LuaRocks: + +```sh +luarocks install lua-lumo-orm +``` + +Or clone the repository manually: + +```sh +git clone https://github.com/bhhaskin/lua-lumo-orm.git +cd lua-lumo-orm +luarocks make +``` + +## Usage + +### Connecting to a Database + +```lua +local Lumo = require("lumo") +Lumo.connect("database.sqlite") +``` + +### Defining a Model + +```lua +local Model = require("lumo.model") + +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +return User +``` + +### Querying Data + +```lua +local User = require("models.user") + +-- Fetch all users +local users = User:all() + +-- Find a user by ID +local user = User:find(1) +``` + +### Creating a Record + +```lua +local newUser = User:create({ name = "Alice", email = "alice@example.com" }) +print("Created user:", newUser.id) +``` + +### Updating a Record + +```lua +user:update({ name = "Alice Wonderland" }) +``` + +### Deleting a Record + +```lua +user:delete() +``` + +### Running Migrations + +To apply migrations: +```sh +lua bin/migrate.lua up +``` + +To rollback: +```sh +lua bin/migrate.lua down +``` + +## Running Tests + +Lumo ORM includes a test suite using `busted`. You can run tests manually with: + +```sh +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** +```sh +make build +``` +This will build the Docker image using `Dockerfile.dev`. + +#### **Run Tests** +```sh +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** +```sh +make shell +``` +This will open an interactive shell inside the Docker container for debugging. + +#### **Clean Up Docker Images** +```sh +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. \ No newline at end of file diff --git a/bin/migrate.lua b/bin/migrate.lua new file mode 100644 index 0000000..881df5b --- /dev/null +++ b/bin/migrate.lua @@ -0,0 +1,20 @@ +local Lumo = require("lumo") +Lumo.connect("database.sqlite") + +local Migrations = require("lumo.migrations") + +-- Load migration files +local migrations = { + -- require("lumo.migrations.user_migration"), -- Add more here +} + +-- CLI +local action = arg[1] + +if action == "up" then + Migrations:migrateUp(migrations) +elseif action == "down" then + Migrations:migrateDown(migrations) +else + print("Usage: lua migrate.lua up | down") +end \ No newline at end of file diff --git a/bin/seed.lua b/bin/seed.lua new file mode 100644 index 0000000..2a02a2c --- /dev/null +++ b/bin/seed.lua @@ -0,0 +1,8 @@ +local Lumo = require("lumo") +Lumo.connect("database.sqlite") + +local Seeder = require("lumo.seeder") + +print("Running database seeder...") +Seeder:run() +print("Seeding finished.") \ No newline at end of file diff --git a/makefile b/makefile new file mode 100644 index 0000000..d925bb3 --- /dev/null +++ b/makefile @@ -0,0 +1,26 @@ +# Docker settings +IMAGE_NAME=lua-lumo +DOCKER_FILE=Dockerfile.dev + +# Declare phony targets (these are not filenames) +.PHONY: build test shell clean + +# Build Docker image +build: + @echo "๐Ÿ› ๏ธ Building Docker image $(IMAGE_NAME)..." + docker build -t $(IMAGE_NAME) -f $(DOCKER_FILE) . + +# Run tests using Docker container +test: build + @echo "โœ… Running tests..." + docker run --rm $(IMAGE_NAME) + +# Open interactive shell in Docker container (useful for debugging) +shell: build + @echo "๐Ÿš Opening shell in Docker container..." + docker run --rm -it $(IMAGE_NAME) sh + +# Clean up Docker image +clean: + @echo "๐Ÿงน Removing Docker image..." + docker rmi -f $(IMAGE_NAME) \ No newline at end of file diff --git a/rockspecs/lumo-orm-0.1-0.rockspec b/rockspecs/lumo-orm-0.1-0.rockspec new file mode 100644 index 0000000..73854c6 --- /dev/null +++ b/rockspecs/lumo-orm-0.1-0.rockspec @@ -0,0 +1,31 @@ +package = "lumo-orm" +version = "0.1-0" +source = { + url = "git+https://github.com/bhhaskin/lua-lumo-orm.git", + branch = "main" +} +description = { + summary = "A lightweight Active Record ORM for Lua with SQLite support", + detailed = [[ + Lumo-ORM provides an Eloquent-style ORM for Lua, built to work with SQLite. + It includes migrations, a query builder, relationships, and Active Record pattern support. + ]], + license = "MIT", + homepage = "https://github.com/bhhaskin/lua-lumo-orm" +} +dependencies = { + "lua >= 5.1", + "lsqlite3complete" +} +build = { + type = "builtin", + modules = { + ["lumo"] = "src/lumo.lua", + ["lumo.db"] = "src/db.lua", + ["lumo.model"] = "src/model.lua", + ["lumo.query_builder"] = "src/query_builder.lua", + ["lumo.relationships"] = "src/relationships.lua", + ["lumo.migrations"] = "src/migrations.lua", + ["lumo.seeder"] = "src/seeder.lua", + } +} diff --git a/spec/db_spec.lua b/spec/db_spec.lua new file mode 100644 index 0000000..b8fd4d8 --- /dev/null +++ b/spec/db_spec.lua @@ -0,0 +1,63 @@ +local DB = require("lumo.db") +local busted = require("busted") + +describe("Database Connection", function() + local db + + before_each(function() + db = DB.connect(":memory:") -- Create a fresh database before each test + db:execute([[CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT);]]) -- Create new table + end) + + after_each(function() + if db then + db:close() -- Close database after each test + db = nil + end + end) + + it("should connect to an in-memory SQLite database", function() + assert.is_not_nil(db) + end) + + describe("Database Queries", function() + + it("should insert a record", function() + local result = db:execute("INSERT INTO test (name) VALUES (?);", "Alice") + assert.is_true(result) + end) + + it("should retrieve inserted records", function() + db:execute("INSERT INTO test (name) VALUES (?);", "Alice") + + local rows = db:query("SELECT * FROM test;") + assert.is_not_nil(rows) + assert.are.equal(1, #rows) + assert.are.equal("Alice", rows[1].name) + end) + + it("should delete a record", function() + db:execute("INSERT INTO test (name) VALUES (?);", "Alice") + + local result = db:execute("DELETE FROM test WHERE name = ?;", "Alice") + assert.is_true(result) + + local rows = db:query("SELECT * FROM test;") + assert.are.equal(0, #rows) + end) + + it("should retrieve the last inserted ID", function() + db:execute("INSERT INTO test (name) VALUES (?);", "Alice") + local last_id = db:lastInsertId() + + assert.is_not_nil(last_id) + assert.are.equal(1, last_id) -- First record should have ID 1 + + db:execute("INSERT INTO test (name) VALUES (?);", "Bob") + local last_id_2 = db:lastInsertId() + + assert.is_not_nil(last_id_2) + assert.are.equal(2, last_id_2) -- Second record should have ID 2 + end) + end) +end) diff --git a/spec/model_spec.lua b/spec/model_spec.lua new file mode 100644 index 0000000..9a6b254 --- /dev/null +++ b/spec/model_spec.lua @@ -0,0 +1,69 @@ +local DB = require("lumo.db") +local Model = require("lumo.model") +local busted = require("busted") + +-- Define a test model +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +describe("Model", function() + local db + + before_each(function() + db = DB.connect(":memory:") -- Use in-memory SQLite database + db:execute([[DROP TABLE IF EXISTS users;]]) + db:execute([[CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);]]) + Model.setDB(db) -- Ensure models have a database connection + end) + + after_each(function() + if db then + db:close() + db = nil + end + end) + + it("should insert a record", function() + local user = User:create({ name = "Alice", email = "alice@example.com" }) + assert.is_not_nil(user) + assert.is_not_nil(user.id) + assert.are.equal("Alice", user.name) + assert.are.equal("alice@example.com", user.email) + end) + + it("should retrieve a record by ID", function() + local user = User:create({ name = "Alice", email = "alice@example.com" }) + local found = User:find(user.id) + assert.is_not_nil(found) + assert.are.equal("Alice", found.name) + assert.are.equal("alice@example.com", found.email) + end) + + it("should update a record", function() + local user = User:create({ name = "Alice", email = "alice@example.com" }) + local success = user:update({ email = "alice@new.com" }) + assert.is_true(success) + + local updated = User:find(user.id) + assert.is_not_nil(updated) + assert.are.equal("alice@new.com", updated.email) + end) + + it("should delete a record", function() + local user = User:create({ name = "Alice", email = "alice@example.com" }) + local success = user:delete() + assert.is_true(success) + + local deleted = User:find(user.id) + assert.is_nil(deleted) + end) + + it("should retrieve all records", function() + User:create({ name = "Alice", email = "alice@example.com" }) + User:create({ name = "Bob", email = "bob@example.com" }) + + local users = User:all() + assert.are.equal(2, #users) + end) +end) diff --git a/spec/query_builder_spec.lua b/spec/query_builder_spec.lua new file mode 100644 index 0000000..10856c8 --- /dev/null +++ b/spec/query_builder_spec.lua @@ -0,0 +1,61 @@ +local DB = require("lumo.db") +local QueryBuilder = require("lumo.query_builder") +local busted = require("busted") + +describe("Query Builder", function() + local db + + before_each(function() + db = DB.connect(":memory:") -- Create a fresh database before each test + db:execute([[DROP TABLE IF EXISTS users;]]) + db:execute([[CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);]]) + QueryBuilder.setDB(db) + end) + + after_each(function() + if db then + db:close() + db = nil + end + end) + + it("should insert a record", function() + local id = QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) + assert.is_not_nil(id) + assert.are.equal(1, id) -- Should be first inserted row + end) + + it("should retrieve a record", function() + QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) + local user = QueryBuilder:new("users"):where("name", "=", "Alice"):first() + assert.is_not_nil(user) + assert.are.equal("Alice", user.name) + assert.are.equal("alice@example.com", user.email) + end) + + it("should update a record", function() + QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) + local updated = QueryBuilder:new("users"):where("name", "=", "Alice"):update({ email = "alice@new.com" }) + assert.is_true(updated) + + local user = QueryBuilder:new("users"):where("name", "=", "Alice"):first() + assert.are.equal("alice@new.com", user.email) + end) + + it("should delete a record", function() + QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) + local deleted = QueryBuilder:new("users"):where("name", "=", "Alice"):delete() + assert.is_true(deleted) + + local user = QueryBuilder:new("users"):where("name", "=", "Alice"):first() + assert.is_nil(user) + end) + + it("should retrieve multiple records", function() + QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) + QueryBuilder:new("users"):insert({ name = "Bob", email = "bob@example.com" }) + + local users = QueryBuilder:new("users"):get() + assert.are.equal(2, #users) + end) +end) \ No newline at end of file diff --git a/spec/relationships_spec.lua b/spec/relationships_spec.lua new file mode 100644 index 0000000..f8dbe0f --- /dev/null +++ b/spec/relationships_spec.lua @@ -0,0 +1,82 @@ +local DB = require("lumo.db") +local Model = require("lumo.model") +local busted = require("busted") + +-- Define test models +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +local Post = setmetatable({}, Model) +Post.__index = Post +Post.table = "posts" + +describe("Model Relationships", function() + local db + + before_each(function() + db = DB.connect(":memory:") + db:execute([[DROP TABLE IF EXISTS users;]]) + db:execute([[DROP TABLE IF EXISTS posts;]]) + db:execute([[CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);]]) + db:execute([[CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, user_id INTEGER, FOREIGN KEY(user_id) REFERENCES users(id));]]) + Model.setDB(db) + + -- Define relationships inside `before_each` + function User:posts() + return self:hasMany(Post, "user_id") + end + + function Post:user() + return self:belongsTo(User, "user_id") + end + + -- Define cascade delete behavior for User + User.__cascadeDelete = { "posts" } + end) + + after_each(function() + if db then + db:close() + db = nil + end + end) + + it("should retrieve a user's posts", function() + local user = User:create({ name = "Alice" }) + Post:create({ title = "First Post", user_id = user.id }) + Post:create({ title = "Second Post", user_id = user.id }) + + local posts = user:posts() + assert.are.equal(2, #posts) + assert.are.equal("First Post", posts[1].title) + assert.are.equal("Second Post", posts[2].title) + end) + + it("should retrieve the user of a post", function() + local user = User:create({ name = "Alice" }) + local post = Post:create({ title = "First Post", user_id = user.id }) + + local post_user = post:user() + assert.is_not_nil(post_user) + assert.are.equal("Alice", post_user.name) + end) + + it("should cascade delete a user's posts", function() + local user = User:create({ name = "Alice" }) + local post1 = Post:create({ title = "First Post", user_id = user.id }) + local post2 = Post:create({ title = "Second Post", user_id = user.id }) + + -- Ensure posts exist before deletion + local posts_before = user:posts() + assert.are.equal(2, #posts_before) + + -- Delete the user (should cascade delete posts) + local success = user:delete() + assert.is_true(success) + + -- Ensure posts are deleted + local posts_after = Post:all() + assert.are.equal(0, #posts_after) + end) +end) \ No newline at end of file diff --git a/src/lumo.lua b/src/lumo.lua new file mode 100644 index 0000000..375e365 --- /dev/null +++ b/src/lumo.lua @@ -0,0 +1,22 @@ +local DB = require("lumo.db") +local Model = require("lumo.model") +local QueryBuilder = require("lumo.query_builder") +local Migrations = require("lumo.migrations") + +local Lumo = { + _VERSION = '0.1-0' +} + +function Lumo.connect(db_path) + local db = DB.connect(db_path) + Model.setDB(db) + QueryBuilder.setDB(db) + Migrations.setDB(db) + return db +end + +Lumo.Model = Model +Lumo.QueryBuilder = QueryBuilder +Lumo.Migrations = Migrations + +return Lumo \ No newline at end of file diff --git a/src/lumo/db.lua b/src/lumo/db.lua new file mode 100644 index 0000000..a2c6497 --- /dev/null +++ b/src/lumo/db.lua @@ -0,0 +1,52 @@ +local sqlite3 = require("lsqlite3complete") + +local DB = {} +DB.__index = DB + +function DB.connect(path) + local instance = setmetatable({}, DB) + instance.db = sqlite3.open(path) + return instance +end + +function DB:close() + if self.db then + self.db:close() + self.db = nil + end +end + +-- Run a query and return rows +function DB:query(sql, ...) + local stmt = self.db:prepare(sql) + if not stmt then return nil end + + stmt:bind_values(...) + + local results = {} + for row in stmt:nrows() do + table.insert(results, row) + end + + stmt:finalize() + return results +end + +-- Execute a statement (for INSERT, UPDATE, DELETE) +function DB:execute(sql, ...) + local stmt = self.db:prepare(sql) + if not stmt then return false end + + stmt:bind_values(...) + local res = stmt:step() + stmt:finalize() + + return res == sqlite3.DONE +end + +-- Get the last inserted ID +function DB:lastInsertId() + return self.db:last_insert_rowid() +end + +return DB diff --git a/src/lumo/migrations.lua b/src/lumo/migrations.lua new file mode 100644 index 0000000..b1d7d67 --- /dev/null +++ b/src/lumo/migrations.lua @@ -0,0 +1,92 @@ +local QueryBuilder = require("lumo.query_builder") + +local Migrations = {} +Migrations.__index = Migrations +Migrations.db = nil -- Will be set dynamically + +-- Set database connection +function Migrations.setDB(db) + Migrations.db = db + QueryBuilder.setDB(db) + Migrations:initialize() +end + +-- Create a migrations table to track applied migrations +function Migrations:initialize() + self.db:execute([[ + CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ]]) +end + +-- Check if a migration has been applied +function Migrations:hasRun(name) + local result = QueryBuilder:new("migrations"):where("name", "=", name):get() + return #result > 0 +end + +-- Apply a migration +function Migrations:apply(name, up) + if self:hasRun(name) then + print("Migration already applied:", name) + return false + end + + -- Run the `up` migration (table creation/modification) + self.db:execute(up) + + -- Record migration as applied + QueryBuilder:new("migrations"):insert({ name = name }) + print("Migration applied:", name) + return true +end + +-- Rollback a migration +function Migrations:rollback(name, down) + if not self:hasRun(name) then + print("Migration not found:", name) + return false + end + + -- Run the `down` migration (rollback) + self.db:execute(down) + + -- Remove the migration record + QueryBuilder:new("migrations"):where("name", "=", name):delete() + print("Migration rolled back:", name) + return true +end + +-- Apply all pending migrations +function Migrations:migrateUp(migrations) + for _, migration in ipairs(migrations) do + self:apply(migration.name, migration.up) + end +end + +-- Rollback all applied migrations +function Migrations:migrateDown(migrations) + for i = #migrations, 1, -1 do + self:rollback(migrations[i].name, migrations[i].down) + end +end + +-- Create pivot table if not exists +function Migrations:createPivotTable(name, column1, column2) + local sql = string.format([[ + CREATE TABLE IF NOT EXISTS %s ( + %s INTEGER NOT NULL, + %s INTEGER NOT NULL, + PRIMARY KEY (%s, %s), + FOREIGN KEY (%s) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (%s) REFERENCES roles(id) ON DELETE CASCADE + ); + ]], name, column1, column2, column1, column2, column1, column2) + + self.db:execute(sql) +end + +return Migrations diff --git a/src/lumo/model.lua b/src/lumo/model.lua new file mode 100644 index 0000000..616f383 --- /dev/null +++ b/src/lumo/model.lua @@ -0,0 +1,95 @@ +local QueryBuilder = require("lumo.query_builder") +local Relationships = require("lumo.relationships") -- Import relationships module + +local Model = {} +Model.__index = Model +Model.db = nil -- Will be set dynamically + +-- Set the database connection (called in lumo.lua) +function Model.setDB(db) + Model.db = db + QueryBuilder.setDB(db) -- Ensure QueryBuilder uses the same connection +end + +-- Constructor (Create a new model instance) +function Model:new(data) + local instance = setmetatable(data or {}, self) + return instance +end + +-- Get a query builder instance for this model's table +function Model:query() + return QueryBuilder:new(self.table) +end + +-- Find a record by ID +function Model:find(id) + local result = self:query():where("id", "=", id):limit(1):get() + if #result > 0 then + return self:new(result[1]) -- Return a new instance with data + end + return nil +end + +-- Get all records +function Model:all() + local result = self:query():get() + local instances = {} + for _, row in ipairs(result) do + table.insert(instances, self:new(row)) + end + return instances +end + +-- Insert a new record +function Model:create(data) + local id = self:query():insert(data) + return self:find(id) -- Return the newly created record +end + +-- Update an existing record +function Model:update(data) + if not self.id then error("Cannot update a record without an ID") end + local success = self:query():where("id", "=", self.id):update(data) + return success == true +end + +-- Delete a record +function Model:delete() + if not self.id then error("Cannot delete a record without an ID") end + + -- Run cascade delete hooks (if defined in model) + if self.__cascadeDelete then + for _, relation in ipairs(self.__cascadeDelete) do + if type(self[relation]) == "function" then + local related_records = self[relation](self) -- Fetch related records + if type(related_records) == "table" then + for _, record in ipairs(related_records) do + if record and record.delete then + record:delete() -- Ensure each related record is deleted + end + end + end + end + end + end + + -- Delete the record + local success = self:query():where("id", "=", self.id):delete() + return success +end + + + + + +-- **Use Relationships from `relationships.lua`** +Model.hasOne = Relationships.hasOne +Model.hasMany = Relationships.hasMany +Model.belongsTo = Relationships.belongsTo +Model.belongsToMany = Relationships.belongsToMany +Model.with = Relationships.with +Model.cascadeDelete = Relationships.cascadeDelete +Model.cascadeDeletePivot = Relationships.cascadeDeletePivot + +return Model diff --git a/src/lumo/query_builder.lua b/src/lumo/query_builder.lua new file mode 100644 index 0000000..02c25e5 --- /dev/null +++ b/src/lumo/query_builder.lua @@ -0,0 +1,102 @@ +local QueryBuilder = {} +QueryBuilder.__index = QueryBuilder +QueryBuilder.db = nil -- Will be set dynamically + +-- Set the database connection +function QueryBuilder.setDB(db) + QueryBuilder.db = db +end + +-- Constructor +function QueryBuilder:new(table) + local instance = setmetatable({}, self) + instance.table = table + instance.conditions = {} + instance.params = {} + return instance +end + +-- Add WHERE conditions +function QueryBuilder:where(field, operator, value) + table.insert(self.conditions, field .. " " .. operator .. " ?") + table.insert(self.params, value) + return self +end + +-- Execute the query +function QueryBuilder:get() + local sql = "SELECT * FROM " .. self.table + + if #self.conditions > 0 then + sql = sql .. " WHERE " .. table.concat(self.conditions, " AND ") + end + + return self.db:query(sql, table.unpack(self.params)) +end + +-- Insert a new record +function QueryBuilder:insert(data) + local columns, placeholders, values = {}, {}, {} + + for column, value in pairs(data) do + table.insert(columns, column) + table.insert(values, value) + table.insert(placeholders, "?") + end + + local sql = string.format( + "INSERT INTO %s (%s) VALUES (%s)", + self.table, + table.concat(columns, ", "), + table.concat(placeholders, ", ") + ) + + local success = self.db:execute(sql, table.unpack(values)) + return success and self.db:lastInsertId() or nil +end + +-- Update existing records and return success +function QueryBuilder:update(data) + local updates, values = {}, {} + + for column, value in pairs(data) do + table.insert(updates, column .. " = ?") + table.insert(values, value) + end + + local sql = string.format("UPDATE %s SET %s", self.table, table.concat(updates, ", ")) + + if #self.conditions > 0 then + sql = sql .. " WHERE " .. table.concat(self.conditions, " AND ") + for _, param in ipairs(self.params) do + table.insert(values, param) + end + end + + return self.db:execute(sql, table.unpack(values)) +end + +-- Delete records and return success +function QueryBuilder:delete() + local sql = "DELETE FROM " .. self.table + + if #self.conditions > 0 then + sql = sql .. " WHERE " .. table.concat(self.conditions, " AND ") + end + + return self.db:execute(sql, table.unpack(self.params)) +end + +-- Get first record from query +function QueryBuilder:first() + local results = self:get() + return results and results[1] or nil +end + +function QueryBuilder:limit(count) + self.limit_count = "LIMIT " .. count + return self +end + + +return QueryBuilder diff --git a/src/lumo/relationships.lua b/src/lumo/relationships.lua new file mode 100644 index 0000000..f29b171 --- /dev/null +++ b/src/lumo/relationships.lua @@ -0,0 +1,88 @@ +local QueryBuilder = require("lumo.query_builder") + +local Relationships = {} +Relationships.__index = Relationships + +-- One-to-One relationship (with eager loading) +function Relationships:hasOne(model, foreignKey, localKey) + if not model or not model.query then + error("Invalid model passed to hasOne()") + end + localKey = localKey or "id" + return model:query():where(foreignKey, "=", self[localKey]):limit(1):get()[1] +end + +-- One-to-Many relationship (with eager loading) +function Relationships:hasMany(model, foreignKey, localKey) + if not model or not model.query then + error("Invalid model passed to hasMany()") + end + localKey = localKey or "id" + return model:query():where(foreignKey, "=", self[localKey]):get() +end + +-- Belongs-To relationship (with eager loading) +function Relationships:belongsTo(model, foreignKey, localKey) + if not model or not model.query then + error("Invalid model passed to belongsTo()") + end + localKey = localKey or "id" + return model:query():where(localKey, "=", self[foreignKey]):limit(1):get()[1] +end + +-- Many-to-Many relationship (via pivot table with eager loading) +function Relationships:belongsToMany(model, pivotTable, localKey, foreignKey) + if not model or not model.query then + error("Invalid model passed to belongsToMany()") + end + localKey = localKey or "id" + + local results = QueryBuilder:new(pivotTable) + :where(localKey, "=", self.id) + :get() + + local relatedIDs = {} + for _, row in ipairs(results) do + table.insert(relatedIDs, row[foreignKey]) + end + + if #relatedIDs > 0 then + return model:query():where("id", "IN", "(" .. table.concat(relatedIDs, ",") .. ")"):get() + end + return {} +end + +-- Eager load relationships to avoid N+1 queries +function Relationships:with(relations) + if type(relations) ~= "table" then + relations = { relations } -- Allow single or multiple relationships + end + + for _, relation in ipairs(relations) do + if self[relation] and type(self[relation]) == "function" then + self[relation .. "_loaded"] = self[relation](self) + end + end + + return self +end + +-- Cascade delete for One-to-Many +function Relationships:cascadeDelete(model, foreignKey) + if not model or not model.query then + error("Invalid model passed to cascadeDelete()") + end + local relatedRecords = self:hasMany(model, foreignKey) + for _, record in ipairs(relatedRecords) do + record:delete() -- Delete each related record + end +end + +-- Cascade delete for Many-to-Many (remove pivot entries) +function Relationships:cascadeDeletePivot(pivotTable, localKey) + QueryBuilder:new(pivotTable) + :where(localKey, "=", self.id) + :delete() +end + +return Relationships From 7d43f72cc2459b19c7c2eb8349b19d3790fa3a45 Mon Sep 17 00:00:00 2001 From: Bryan Haskin Date: Thu, 27 Feb 2025 14:03:06 -0800 Subject: [PATCH 2/4] updated tests --- spec/lumo_spec.lua | 38 +++++++++++++++++++++++ spec/migrations_spec.lua | 60 +++++++++++++++++++++++++++++++++++++ spec/relationships_spec.lua | 10 +++++-- src/lumo.lua | 19 ++++++++---- src/lumo/model.lua | 4 --- 5 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 spec/lumo_spec.lua create mode 100644 spec/migrations_spec.lua diff --git a/spec/lumo_spec.lua b/spec/lumo_spec.lua new file mode 100644 index 0000000..b256eee --- /dev/null +++ b/spec/lumo_spec.lua @@ -0,0 +1,38 @@ +local DB = require("lumo.db") +local Lumo = require("lumo") +local busted = require("busted") + +describe("Lumo ORM", function() + local db + + before_each(function() + db = DB.connect(":memory:") -- Use in-memory SQLite database + Lumo.connect(":memory:") -- Ensure Lumo connects to the database + end) + + after_each(function() + if db then + db:close() + db = nil + end + end) + + it("should connect to a database", function() + assert.is_not_nil(Lumo.db) + end) + + it("should provide access to QueryBuilder", function() + local query = Lumo.query("users") + assert.is_not_nil(query) + assert.are.equal("users", query.table) + end) + + it("should execute raw SQL queries", function() + Lumo.db:execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT);") + Lumo.db:execute("INSERT INTO test (name) VALUES ('Alice');") + + local result = Lumo.db:query("SELECT * FROM test;") + assert.is_not_nil(result[1]) + assert.are.equal("Alice", result[1].name) + end) +end) diff --git a/spec/migrations_spec.lua b/spec/migrations_spec.lua new file mode 100644 index 0000000..d9f74c8 --- /dev/null +++ b/spec/migrations_spec.lua @@ -0,0 +1,60 @@ +local DB = require("lumo.db") +local Migrations = require("lumo.migrations") +local busted = require("busted") + +describe("Migrations", function() + local db + + before_each(function() + db = DB.connect(":memory:") -- Use in-memory SQLite database + Migrations.setDB(db) -- Ensure Migrations has a database connection + end) + + after_each(function() + if db then + db:close() + db = nil + end + end) + + it("should initialize the migrations table", function() + local result = db:query("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';") + assert.is_not_nil(result[1]) + assert.are.equal("migrations", result[1].name) + end) + + it("should apply a migration", function() + local migration_name = "create_users_table" + local up_sql = "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);" + + local applied = Migrations:apply(migration_name, up_sql) + assert.is_true(applied) + + local result = db:query("SELECT name FROM sqlite_master WHERE type='table' AND name='users';") + assert.is_not_nil(result[1]) + assert.are.equal("users", result[1].name) + end) + + it("should not apply the same migration twice", function() + local migration_name = "create_users_table" + local up_sql = "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);" + + Migrations:apply(migration_name, up_sql) + local reapplied = Migrations:apply(migration_name, up_sql) + + assert.is_false(reapplied) + end) + + it("should rollback a migration", function() + local migration_name = "create_users_table" + local up_sql = "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);" + local down_sql = "DROP TABLE users;" + + Migrations:apply(migration_name, up_sql) + local rolled_back = Migrations:rollback(migration_name, down_sql) + assert.is_true(rolled_back) + + local result = db:query("SELECT name FROM sqlite_master WHERE type='table' AND name='users';") + assert.are.equal(0, #result) + end) +end) \ No newline at end of file diff --git a/spec/relationships_spec.lua b/spec/relationships_spec.lua index f8dbe0f..90e7dc2 100644 --- a/spec/relationships_spec.lua +++ b/spec/relationships_spec.lua @@ -24,8 +24,14 @@ describe("Model Relationships", function() -- Define relationships inside `before_each` function User:posts() - return self:hasMany(Post, "user_id") - end + local results = self:hasMany(Post, "user_id") + local instances = {} + for _, row in ipairs(results) do + table.insert(instances, Post:new(row)) -- Convert table rows into Post model instances + end + return instances + end + function Post:user() return self:belongsTo(User, "user_id") diff --git a/src/lumo.lua b/src/lumo.lua index 375e365..d561dc1 100644 --- a/src/lumo.lua +++ b/src/lumo.lua @@ -4,15 +4,22 @@ local QueryBuilder = require("lumo.query_builder") local Migrations = require("lumo.migrations") local Lumo = { - _VERSION = '0.1-0' + _VERSION = '0.1-0', + db = nil -- Store database connection } function Lumo.connect(db_path) - local db = DB.connect(db_path) - Model.setDB(db) - QueryBuilder.setDB(db) - Migrations.setDB(db) - return db + Lumo.db = DB.connect(db_path) -- Store DB connection in Lumo.db + Model.setDB(Lumo.db) + QueryBuilder.setDB(Lumo.db) + Migrations.setDB(Lumo.db) + return Lumo.db +end + +-- Provide access to QueryBuilder +function Lumo.query(table) + if not Lumo.db then error("Database not connected. Call Lumo.connect() first.") end + return QueryBuilder:new(table) end Lumo.Model = Model diff --git a/src/lumo/model.lua b/src/lumo/model.lua index 616f383..ac28e6e 100644 --- a/src/lumo/model.lua +++ b/src/lumo/model.lua @@ -79,10 +79,6 @@ function Model:delete() return success end - - - - -- **Use Relationships from `relationships.lua`** Model.hasOne = Relationships.hasOne Model.hasMany = Relationships.hasMany From 9ec7be7d17e8943e207795c5560f0391319c4a80 Mon Sep 17 00:00:00 2001 From: Bryan Haskin Date: Thu, 27 Feb 2025 14:12:03 -0800 Subject: [PATCH 3/4] added examples --- docs/examples/db.lua | 29 ++++++++++++++ docs/examples/lumo.lua | 49 +++++++++++++++++++++++ docs/examples/migrations.lua | 38 ++++++++++++++++++ docs/examples/model.lua | 44 +++++++++++++++++++++ docs/examples/query_builder.lua | 39 ++++++++++++++++++ docs/examples/relationships.lua | 70 +++++++++++++++++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 docs/examples/db.lua create mode 100644 docs/examples/lumo.lua create mode 100644 docs/examples/migrations.lua create mode 100644 docs/examples/model.lua create mode 100644 docs/examples/query_builder.lua create mode 100644 docs/examples/relationships.lua diff --git a/docs/examples/db.lua b/docs/examples/db.lua new file mode 100644 index 0000000..7adff79 --- /dev/null +++ b/docs/examples/db.lua @@ -0,0 +1,29 @@ +-- Example usage of lumo.db + +local DB = require("lumo.db") + +-- Connect to a SQLite database +local db = DB.connect("example.sqlite") + +-- Create a table +local create_table_sql = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); +]] +db:execute(create_table_sql) + +-- Insert a record +local insert_sql = "INSERT INTO users (name, email) VALUES (?, ?);" +db:execute(insert_sql, "Alice", "alice@example.com") + +-- Query records +local results = db:query("SELECT * FROM users;") +for _, user in ipairs(results) do + print("User:", user.id, user.name, user.email) +end + +-- Close the database connection +db:close() diff --git a/docs/examples/lumo.lua b/docs/examples/lumo.lua new file mode 100644 index 0000000..3e85dfb --- /dev/null +++ b/docs/examples/lumo.lua @@ -0,0 +1,49 @@ +-- Example usage of Lumo ORM + +local Lumo = require("lumo") + +-- Connect to a SQLite database +Lumo.connect("example.sqlite") + +-- Define a User model +local User = setmetatable({}, Lumo.Model) +User.__index = User +User.table = "users" + +-- Create a users table +local create_table_sql = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); +]] +Lumo.db:execute(create_table_sql) + +-- Insert a user +local user = User:create({ name = "Alice", email = "alice@example.com" }) +print("Inserted User:", user.id, user.name, user.email) + +-- Find a user by ID +local found_user = User:find(user.id) +if found_user then + print("User Found:", found_user.id, found_user.name, found_user.email) +end + +-- Update a user +local success = found_user:update({ name = "Alice Updated" }) +print("Update Successful:", success) + +-- Retrieve all users +local users = User:all() +print("All Users:") +for _, u in ipairs(users) do + print(u.id, u.name, u.email) +end + +-- Delete a user +local deleted = found_user:delete() +print("Delete Successful:", deleted) + +-- Close the database connection +Lumo.db:close() diff --git a/docs/examples/migrations.lua b/docs/examples/migrations.lua new file mode 100644 index 0000000..950cd01 --- /dev/null +++ b/docs/examples/migrations.lua @@ -0,0 +1,38 @@ +-- Example usage of lumo.migrations + +local DB = require("lumo.db") +local Migrations = require("lumo.migrations") + +-- Connect to a SQLite database +local db = DB.connect("example.sqlite") +Migrations.setDB(db) + +-- Define a migration +local migration_name = "create_users_table" +local up_sql = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); +]] +local down_sql = "DROP TABLE users;" + +-- Apply the migration +local applied = Migrations:apply(migration_name, up_sql) +if applied then + print("Migration applied:", migration_name) +else + print("Migration already applied:", migration_name) +end + +-- Rollback the migration +local rolled_back = Migrations:rollback(migration_name, down_sql) +if rolled_back then + print("Migration rolled back:", migration_name) +else + print("Migration not found:", migration_name) +end + +-- Close the database connection +db:close() diff --git a/docs/examples/model.lua b/docs/examples/model.lua new file mode 100644 index 0000000..afa7682 --- /dev/null +++ b/docs/examples/model.lua @@ -0,0 +1,44 @@ +-- Example usage of lumo.model + +local DB = require("lumo.db") +local Model = require("lumo.model") + +-- Connect to a SQLite database +local db = DB.connect("example.sqlite") +Model.setDB(db) + +-- Define a User model +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +-- Create a users table +local create_table_sql = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); +]] +db:execute(create_table_sql) + +-- Insert a user +local user = User:create({ name = "Alice", email = "alice@example.com" }) +print("Inserted User:", user.id, user.name, user.email) + +-- Find a user by ID +local found_user = User:find(user.id) +if found_user then + print("User Found:", found_user.id, found_user.name, found_user.email) +end + +-- Update a user +local success = found_user:update({ name = "Alice Updated" }) +print("Update Successful:", success) + +-- Delete a user +local deleted = found_user:delete() +print("Delete Successful:", deleted) + +-- Close the database connection +db:close() diff --git a/docs/examples/query_builder.lua b/docs/examples/query_builder.lua new file mode 100644 index 0000000..2c91f98 --- /dev/null +++ b/docs/examples/query_builder.lua @@ -0,0 +1,39 @@ +-- Example usage of lumo.query_builder + +local DB = require("lumo.db") +local QueryBuilder = require("lumo.query_builder") + +-- Connect to a SQLite database +local db = DB.connect("example.sqlite") +QueryBuilder.setDB(db) + +-- Create a table +local create_table_sql = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL + ); +]] +db:execute(create_table_sql) + +-- Insert a record using QueryBuilder +local user_id = QueryBuilder:new("users"):insert({ name = "Alice", email = "alice@example.com" }) +print("Inserted User ID:", user_id) + +-- Retrieve a user +local user = QueryBuilder:new("users"):where("id", "=", user_id):first() +if user then + print("User Found:", user.id, user.name, user.email) +end + +-- Update a user +local updated = QueryBuilder:new("users"):where("id", "=", user_id):update({ name = "Alice Updated" }) +print("Update Successful:", updated) + +-- Delete a user +local deleted = QueryBuilder:new("users"):where("id", "=", user_id):delete() +print("Delete Successful:", deleted) + +-- Close the database connection +db:close() diff --git a/docs/examples/relationships.lua b/docs/examples/relationships.lua new file mode 100644 index 0000000..4fc946f --- /dev/null +++ b/docs/examples/relationships.lua @@ -0,0 +1,70 @@ +-- Example usage of lumo.relationships + +local DB = require("lumo.db") +local Model = require("lumo.model") + +-- Connect to a SQLite database +local db = DB.connect("example.sqlite") +Model.setDB(db) + +-- Define User model +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +function User:posts() + return self:hasMany(Post, "user_id") +end + +-- Define Post model +local Post = setmetatable({}, Model) +Post.__index = Post +Post.table = "posts" + +function Post:user() + return self:belongsTo(User, "user_id") +end + +-- Create tables +local create_users_table = [[ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); +]] + +db:execute(create_users_table) + +local create_posts_table = [[ + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); +]] + +db:execute(create_posts_table) + +-- Insert a user +local user = User:create({ name = "Alice" }) +print("Inserted User:", user.id, user.name) + +-- Insert posts for the user +local post1 = Post:create({ title = "First Post", user_id = user.id }) +local post2 = Post:create({ title = "Second Post", user_id = user.id }) +print("Inserted Posts:", post1.id, post1.title, "|", post2.id, post2.title) + +-- Retrieve user's posts +local posts = user:posts() +print("User's Posts:") +for _, post in ipairs(posts) do + print(post.id, post.title) +end + +-- Retrieve post's user +local post_user = post1:user() +print("Post belongs to User:", post_user.id, post_user.name) + +-- Close the database connection +db:close() \ No newline at end of file From fdac497de9a6c998ea5bcbcdf605dd7415be99b1 Mon Sep 17 00:00:00 2001 From: Bryan Haskin Date: Thu, 27 Feb 2025 14:13:40 -0800 Subject: [PATCH 4/4] added work flows and updated rockspec --- .github/workflows/test.yml | 20 ++++++++++++++++++++ rockspecs/lumo-orm-0.1-0.rockspec | 11 +++++------ 2 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a9a0f58 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: CI - Build & Test + +on: + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx (for multi-platform builds if needed) + uses: docker/setup-buildx-action@v3 + + - name: Build and test with Makefile + run: | + make test \ No newline at end of file diff --git a/rockspecs/lumo-orm-0.1-0.rockspec b/rockspecs/lumo-orm-0.1-0.rockspec index 73854c6..57fa06d 100644 --- a/rockspecs/lumo-orm-0.1-0.rockspec +++ b/rockspecs/lumo-orm-0.1-0.rockspec @@ -21,11 +21,10 @@ build = { type = "builtin", modules = { ["lumo"] = "src/lumo.lua", - ["lumo.db"] = "src/db.lua", - ["lumo.model"] = "src/model.lua", - ["lumo.query_builder"] = "src/query_builder.lua", - ["lumo.relationships"] = "src/relationships.lua", - ["lumo.migrations"] = "src/migrations.lua", - ["lumo.seeder"] = "src/seeder.lua", + ["lumo.db"] = "src/lumo/db.lua", + ["lumo.model"] = "src/lumo/model.lua", + ["lumo.query_builder"] = "src/lumo/query_builder.lua", + ["lumo.relationships"] = "src/lumo/relationships.lua", + ["lumo.migrations"] = "src/lumo/migrations.lua", } }