diff --git a/README.md b/README.md index 59c9b8e..decae65 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,45 @@ Lumo ORM is a lightweight, Active Record-style ORM for Lua, designed to work wit 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` + +### 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 @@ -53,11 +86,51 @@ return User ```lua local User = require("models.user") --- Fetch all users -local users = User:all() - --- Find a user by ID +-- Basic queries +local users = User:all() -- Returns a Collection local user = User:find(1) +local first = User:first() + +-- WHERE conditions +local activeUsers = User:where("status", "=", "active") + :where("age", ">", 18) + :orderBy("name", "ASC") + :get() + +-- OR conditions +local admins = User:where("role", "=", "admin") + :orWhere("role", "=", "moderator") + :get() + +-- IN / NOT IN queries +local users = User:whereIn("id", {1, 2, 3, 4, 5}):get() + +-- NULL checks +local verified = User:whereNotNull("email_verified_at"):get() +local unverified = User:whereNull("email_verified_at"):get() + +-- NOT conditions +local notBanned = User:whereNot("status", "=", "banned"):get() + +-- Raw SQL conditions +local users = User:whereRaw("age BETWEEN ? AND ?", 18, 65):get() + +-- Select specific columns +local names = User:select("id", "name", "email"):get() + +-- Distinct results +local countries = User:select("country"):distinct():get() + +-- Working with Collections +for i, user in ipairs(users) do + print(user.name) +end + +-- Collection methods +local names = users:map(function(u) return u.name end) +local adults = users:filter(function(u) return u.age >= 18 end) +local sorted = users:sortBy("name") +local count = users:count() ``` ### Creating a Record @@ -79,6 +152,382 @@ user:update({ name = "Alice Wonderland" }) user:delete() ``` +### Working with Relationships + +```lua +-- Define a User model with posts relationship +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +function User:posts() + return self:hasMany(Post, "user_id") +end + +-- Define a Post model with user relationship +local Post = setmetatable({}, Model) +Post.__index = Post +Post.table = "posts" + +function Post:user() + return self:belongsTo(User, "user_id") +end + +-- Use relationships (returns Model instances) +local user = User:find(1) +local posts = user:posts() -- Returns Collection of Post models + +for i, post in ipairs(posts) do + print(post.title) + post:update({ title = "Updated Title" }) +end + +-- Belongs to relationship +local post = Post:find(1) +local author = post:user() -- Returns User model instance +print(author.name) +``` + +### Cascade Delete + +```lua +-- Define cascade behavior +User.__cascadeDelete = { "posts" } + +-- When user is deleted, all posts are automatically deleted +local user = User:find(1) +user:delete() -- Automatically deletes all user's posts +``` + +### Using Transactions + +```lua +local Lumo = require("lumo") +Lumo.connect("database.sqlite") + +-- Automatic transaction with rollback on error +Lumo.db:transaction(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 }) + -- If any operation fails, all changes are rolled back +end) + +-- Manual transaction control +Lumo.db:beginTransaction() +local user = User:create({ name = "Bob" }) +Lumo.db:commit() +-- Or Lumo.db:rollback() to undo changes +``` + +### Advanced Query Features + +#### Aggregations + +```lua +-- Count records +local total = User:count() +local activeCount = User:where("status", "=", "active"):count() + +-- Sum, Average, Min, Max +local totalViews = Post:sum("views") +local avgAge = User:avg("age") +local youngest = User:min("age") +local oldest = User:max("age") +``` + +#### JOINs + +```lua +-- Inner join +local results = User:query() + :join("posts", "users.id", "=", "posts.user_id") + :where("posts.published", "=", true) + :get() + +-- Left join +local results = User:query() + :leftJoin("posts", "users.id", "=", "posts.user_id") + :get() +``` + +#### GROUP BY and HAVING + +```lua +-- Group by with having +local results = Post:query() + :select("user_id", "COUNT(*) as post_count") + :groupBy("user_id") + :having("post_count", ">", 5) + :get() +``` + +#### Pagination + +```lua +-- Get page 2 with 15 items per page +local users = User:forPage(2, 15):get() + +-- Paginate with metadata +local paginated = User:query():paginate(15, 1) +print(paginated.total) -- Total records +print(paginated.current_page) -- Current page +print(paginated.last_page) -- Total pages +for _, user in ipairs(paginated.data) do + print(user.name) +end +``` + +#### Chunking Large Datasets + +```lua +-- Process 100 records at a time +User:query():chunk(100, function(users, page) + print("Processing page " .. page) + for _, user in ipairs(users) do + -- Process user + end +end) +``` + +#### Bulk Operations + +```lua +-- Insert many records at once +User:query():insertMany({ + { name = "Alice", email = "alice@example.com" }, + { name = "Bob", email = "bob@example.com" }, + { name = "Charlie", email = "charlie@example.com" } +}) +``` + +### Model Features + +#### Auto Timestamps + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" +User.timestamps = true -- Enable auto timestamps + +-- When you create or update, created_at and updated_at are automatic +local user = User:create({ name = "Alice" }) +print(user.created_at, user.updated_at) + +user:update({ name = "Alice Updated" }) +print(user.updated_at) -- Automatically updated +``` + +#### Soft Deletes + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" +User.softDelete = true -- Enable soft deletes + +-- Soft delete (sets deleted_at timestamp) +local user = User:find(1) +user:delete() + +-- Query excludes soft deleted by default +local users = User:all() -- Won't include deleted users + +-- Include soft deleted records +local allUsers = User:withTrashed():all() + +-- Only soft deleted records +local deleted = User:onlyTrashed():all() + +-- Restore soft deleted record +user:restore() + +-- Permanently delete +user:forceDelete() +``` + +#### Attribute Casting + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" +User.casts = { + age = "integer", + is_admin = "boolean", + salary = "number", + settings = "json", + created_at = "datetime" +} + +-- Values are automatically cast +local user = User:find(1) +print(type(user.age)) -- number +print(type(user.is_admin)) -- boolean +``` + +#### Query Scopes + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +-- Define a scope +function User:scopeActive(query) + return query:where("status", "=", "active") +end + +function User:scopeAdult(query) + return query:where("age", ">=", 18) +end + +-- Use scopes +local activeUsers = User:active():get() +local activeAdults = User:active():adult():get() +``` + +#### Mass Assignment Protection + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" +User.fillable = { "name", "email" } -- Only these can be mass-assigned +-- Or use guarded to blacklist fields +-- User.guarded = { "is_admin", "role" } + +local user = User:new() +user:fillAttributes({ + name = "Alice", + email = "alice@example.com", + is_admin = true -- This will be ignored +}) +``` + +#### Model Events/Hooks + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" + +function User:beforeCreate() + print("About to create user") + return true -- Return false to cancel +end + +function User:afterCreate() + print("User created!") + -- Send welcome email, etc. +end + +function User:beforeSave() + -- Hash password, etc. + return true +end + +-- Available hooks: +-- beforeCreate, afterCreate +-- beforeSave, afterSave +-- beforeUpdate, afterUpdate +-- beforeDelete, afterDelete +``` + +#### Validation + +```lua +local User = setmetatable({}, Model) +User.__index = User +User.table = "users" +User.rules = { + name = "required|min:3|max:255", + email = "required|email|unique:users", + age = "numeric|min:18" +} + +-- Validation runs automatically on create +local user = User:create({ + name = "Al", -- Too short + email = "invalid-email" +}) +-- Error: Validation failed: name must be at least 3 characters, email must be a valid email address + +-- Manual validation +local valid, errors = User:validate(data) +if not valid then + print(table.concat(errors, ", ")) +end +``` + +### Advanced Relationships + +#### Has Many Through + +```lua +-- Country -> User -> Post +local Country = setmetatable({}, Model) +Country.__index = Country +Country.table = "countries" + +function Country:posts() + return self:hasManyThrough(Post, User, "country_id", "user_id") +end + +local country = Country:find(1) +local posts = country:posts() -- All posts from users in this country +``` + +#### Polymorphic Relationships + +```lua +-- Comments can belong to either Posts or Videos +local Comment = setmetatable({}, Model) +Comment.__index = Comment +Comment.table = "comments" + +function Comment:commentable() + return self:morphTo("commentable") +end + +-- Post has many comments (polymorphic) +local Post = setmetatable({}, Model) +Post.__index = Post +Post.table = "posts" + +function Post:comments() + return self:morphMany(Comment, "commentable") +end + +local post = Post:find(1) +local comments = post:comments() -- All comments for this post +``` + +### Database Seeding + +```lua +local Seeder = require("lumo.seeder") + +-- Register a seeder +Seeder.register("UserSeeder", function() + local User = require("models.user") + + for i = 1, 10 do + User:create({ + name = Seeder.fake.name(), + email = Seeder.fake.email(), + age = Seeder.fake.number(18, 65), + country = Seeder.fake.choice({"USA", "UK", "Canada"}) + }) + end +end) + +-- Run seeders +Seeder:run() -- Run all +Seeder:runSeeder("UserSeeder") -- Run specific one +``` + ### Running Migrations To apply migrations: diff --git a/docs/examples/db.lua b/docs/examples/db.lua index 7adff79..5df33bf 100644 --- a/docs/examples/db.lua +++ b/docs/examples/db.lua @@ -25,5 +25,18 @@ for _, user in ipairs(results) do print("User:", user.id, user.name, user.email) end +-- Using transactions +db:transaction(function() + db:execute("INSERT INTO users (name, email) VALUES (?, ?);", "Bob", "bob@example.com") + db:execute("INSERT INTO users (name, email) VALUES (?, ?);", "Carol", "carol@example.com") + -- Both inserts succeed or both fail +end) + +-- Manual transaction control +db:beginTransaction() +db:execute("INSERT INTO users (name, email) VALUES (?, ?);", "Dave", "dave@example.com") +db:commit() +-- Or db:rollback() to undo + -- Close the database connection db:close() diff --git a/docs/examples/lumo.lua b/docs/examples/lumo.lua index 3e85dfb..1d2bc72 100644 --- a/docs/examples/lumo.lua +++ b/docs/examples/lumo.lua @@ -34,13 +34,38 @@ end local success = found_user:update({ name = "Alice Updated" }) print("Update Successful:", success) --- Retrieve all users +-- Retrieve all users (returns a Collection) local users = User:all() -print("All Users:") -for _, u in ipairs(users) do +print("Total Users:", #users) + +-- Collections support array-like access and iteration +for i, u in ipairs(users) do print(u.id, u.name, u.email) end +-- Collection methods for functional programming +local names = users:map(function(u) return u.name end) +local emails = users:pluck("email") +local sorted = users:sortBy("name", true) + +print("Names:", table.concat(names:toArray(), ", ")) +print("First user:", users:first().name) +print("Last user:", users:last().name) + +-- Advanced queries with collections +local admins = User:where("role", "=", "admin") + :orWhere("role", "=", "moderator") + :get() + +local activeUsers = User:whereIn("status", {"active", "premium"}):get() + +-- Transactions +Lumo.db:transaction(function() + User:create({ name = "Bob", email = "bob@example.com" }) + User:create({ name = "Carol", email = "carol@example.com" }) + -- If any fails, both are rolled back +end) + -- Delete a user local deleted = found_user:delete() print("Delete Successful:", deleted) diff --git a/docs/examples/query_builder.lua b/docs/examples/query_builder.lua index 2c91f98..acc58ba 100644 --- a/docs/examples/query_builder.lua +++ b/docs/examples/query_builder.lua @@ -27,6 +27,33 @@ if user then print("User Found:", user.id, user.name, user.email) end +-- Query with multiple conditions +local users = QueryBuilder:new("users") + :where("name", "LIKE", "%Alice%") + :where("id", ">", 5) + :orderBy("name", "ASC") + :get() + +print("Found users:", #users) +for i, user in ipairs(users) do + print(user.id, user.name, user.email) +end + +-- OR conditions +local users_or = QueryBuilder:new("users") + :where("id", "=", 1) + :orWhere("id", "=", 2) + :get() + +print("Users with OR condition:", #users_or) + +-- IN queries +local specific_users = QueryBuilder:new("users") + :whereIn("id", {user_id, 2, 3}) + :get() + +print("Users with IN clause:", #specific_users) + -- Update a user local updated = QueryBuilder:new("users"):where("id", "=", user_id):update({ name = "Alice Updated" }) print("Update Successful:", updated) diff --git a/docs/examples/relationships.lua b/docs/examples/relationships.lua index 4fc946f..9dd6e64 100644 --- a/docs/examples/relationships.lua +++ b/docs/examples/relationships.lua @@ -55,16 +55,35 @@ 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 +-- Retrieve user's posts (returns Collection of Post model instances) local posts = user:posts() -print("User's Posts:") -for _, post in ipairs(posts) do - print(post.id, post.title) +print("User has", #posts, "posts") + +-- All posts are Model instances, so you can call methods on them +for i, post in ipairs(posts) do + print("Post:", post.id, post.title) + -- Update the post + post:update({ title = post.title .. " (Updated)" }) end --- Retrieve post's user +-- Collection methods +local titles = posts:map(function(post) return post.title end) +print("Titles:", table.concat(titles:toArray(), ", ")) + +-- Retrieve post's user (returns User model instance) local post_user = post1:user() print("Post belongs to User:", post_user.id, post_user.name) +-- Cascade delete example +User.__cascadeDelete = { "posts" } + +-- When we delete the user, all their posts are automatically deleted +local user_to_delete = User:find(user.id) +user_to_delete:delete() -- This also deletes all posts + +-- Verify posts are deleted +local remaining_posts = Post:all() +print("Remaining posts after cascade delete:", #remaining_posts) + -- Close the database connection db:close() \ No newline at end of file diff --git a/rockspecs/lumo-orm-1.1-0.rockspec b/rockspecs/lumo-orm-1.1-0.rockspec new file mode 100644 index 0000000..56f3bfc --- /dev/null +++ b/rockspecs/lumo-orm-1.1-0.rockspec @@ -0,0 +1,33 @@ +package = "lumo-orm" +version = "1.1-0" +source = { + url = "https://github.com/bhhaskin/lua-lumo-orm/archive/refs/tags/v1.1-0.tar.gz", + dir = "lua-lumo-orm-1.1-0" +} +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", + maintainer = "Bryan Haskin " +} +dependencies = { + "lua >= 5.1", + "lsqlite3complete" +} +build = { + type = "builtin", + modules = { + ["lumo"] = "src/lumo.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", + ["lumo.collection"] = "src/lumo/collection.lua", + ["lumo.seeder"] = "src/lumo/seeder.lua", + } +} diff --git a/spec/relationships_spec.lua b/spec/relationships_spec.lua index 90e7dc2..f8dbe0f 100644 --- a/spec/relationships_spec.lua +++ b/spec/relationships_spec.lua @@ -24,14 +24,8 @@ describe("Model Relationships", function() -- Define relationships inside `before_each` function User:posts() - 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 - + return self:hasMany(Post, "user_id") + end function Post:user() return self:belongsTo(User, "user_id") diff --git a/src/lumo/collection.lua b/src/lumo/collection.lua index 7361da3..6508311 100644 --- a/src/lumo/collection.lua +++ b/src/lumo/collection.lua @@ -1,5 +1,18 @@ local Collection = {} -Collection.__index = Collection + +-- Support # operator for length +Collection.__len = function(self) + return #self.items +end + +-- Support numeric indexing and method access +Collection.__index = function(self, key) + if type(key) == "number" then + return self.items[key] + else + return Collection[key] + end +end -- Create a new collection function Collection:new(items) diff --git a/src/lumo/db.lua b/src/lumo/db.lua index 6eefaef..3953df9 100644 --- a/src/lumo/db.lua +++ b/src/lumo/db.lua @@ -6,6 +6,7 @@ DB.__index = DB function DB.connect(path) local instance = setmetatable({}, DB) instance.db = sqlite3.open(path) + instance.in_transaction = false return instance end @@ -16,18 +17,71 @@ function DB:close() end end +-- Begin a transaction +function DB:beginTransaction() + if self.in_transaction then + error("Transaction already in progress") + end + local success = self:execute("BEGIN TRANSACTION") + if success then + self.in_transaction = true + end + return success +end + +-- Commit a transaction +function DB:commit() + if not self.in_transaction then + error("No transaction in progress") + end + local success = self:execute("COMMIT") + if success then + self.in_transaction = false + end + return success +end + +-- Rollback a transaction +function DB:rollback() + if not self.in_transaction then + error("No transaction in progress") + end + local success = self:execute("ROLLBACK") + if success then + self.in_transaction = false + end + return success +end + +-- Execute a function within a transaction +function DB:transaction(fn) + local success, err = pcall(function() + self:beginTransaction() + fn() + self:commit() + end) + + if not success then + if self.in_transaction then + self:rollback() + end + error(err) + end + + return true +end + -- Run a query and return rows function DB:query(sql, ...) local stmt = self.db:prepare(sql) if not stmt then - print("[SQLite Error] Failed to prepare SQL:", sql, self.db:errmsg()) -- Debug log - return nil + error("[SQLite Error] Failed to prepare SQL: " .. sql .. " - " .. self.db:errmsg()) end if stmt:bind_values(...) ~= sqlite3.OK then - print("[SQLite Error] Failed to bind values:", self.db:errmsg()) -- Debug log + local err = self.db:errmsg() stmt:finalize() - return nil + error("[SQLite Error] Failed to bind values: " .. err) end local results = {} @@ -43,20 +97,23 @@ end function DB:execute(sql, ...) local stmt = self.db:prepare(sql) if not stmt then - print("[SQLite Error] Failed to execute SQL:", sql, self.db:errmsg()) -- Debug log - return false, self.db:errmsg() + error("[SQLite Error] Failed to execute SQL: " .. sql .. " - " .. self.db:errmsg()) end if stmt:bind_values(...) ~= sqlite3.OK then - print("[SQLite Error] Failed to bind values:", self.db:errmsg()) -- Debug log + local err = self.db:errmsg() stmt:finalize() - return false, self.db:errmsg() + error("[SQLite Error] Failed to bind values: " .. err) end local res = stmt:step() stmt:finalize() - return res == sqlite3.DONE + if res ~= sqlite3.DONE then + error("[SQLite Error] Statement execution failed: " .. self.db:errmsg()) + end + + return true end -- Get the last inserted ID diff --git a/src/lumo/model.lua b/src/lumo/model.lua index 9b6f417..eeda1ba 100644 --- a/src/lumo/model.lua +++ b/src/lumo/model.lua @@ -6,37 +6,153 @@ local Model = {} Model.__index = Model Model.db = nil -- Will be set dynamically +-- Model configuration defaults +Model.timestamps = false -- Set to true to auto-manage created_at/updated_at +Model.softDelete = false -- Set to true to enable soft deletes +Model.casts = {} -- Attribute casting definitions +Model.fillable = {} -- Mass-assignment whitelist +Model.guarded = {} -- Mass-assignment blacklist + -- 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 +-- Get current timestamp +function Model:timestamp() + return os.time() +end + +-- Apply attribute casting +function Model:castAttribute(key, value) + local modelClass = getmetatable(self) + if not modelClass.casts or not modelClass.casts[key] then + return value + end + + local cast_type = modelClass.casts[key] + + if cast_type == "integer" or cast_type == "int" then + return tonumber(value) or 0 + elseif cast_type == "number" or cast_type == "float" then + return tonumber(value) or 0.0 + elseif cast_type == "boolean" or cast_type == "bool" then + if type(value) == "number" then + return value ~= 0 + elseif type(value) == "string" then + return value == "true" or value == "1" + end + return value and true or false + elseif cast_type == "string" then + return tostring(value) + elseif cast_type == "json" then + -- Lua doesn't have built-in JSON, so we'll store as string + -- Users can add their own JSON library + return value + elseif cast_type == "datetime" or cast_type == "timestamp" then + return tonumber(value) or os.time() + end + + return value +end + +-- Fire model event hooks +function Model:fireEvent(event, ...) + local modelClass = getmetatable(self) + local hookName = event + if modelClass[hookName] and type(modelClass[hookName]) == "function" then + return modelClass[hookName](self, ...) + end + return true +end + -- Constructor (Create a new model instance) function Model:new(data) local instance = setmetatable({}, self) for key, value in pairs(data or {}) do - instance[key] = value + instance[key] = instance:castAttribute(key, value) end + instance:fireEvent("afterInstantiate") return instance end +-- Fill model with data (respects fillable/guarded) +function Model:fillAttributes(data) + local modelClass = getmetatable(self) + for key, value in pairs(data) do + local canFill = true + + -- Check guarded + if modelClass.guarded and #modelClass.guarded > 0 then + for _, guarded_key in ipairs(modelClass.guarded) do + if guarded_key == key then + canFill = false + break + end + end + end + + -- Check fillable (if defined, only fillable fields allowed) + if canFill and modelClass.fillable and #modelClass.fillable > 0 then + canFill = false + for _, fillable_key in ipairs(modelClass.fillable) do + if fillable_key == key then + canFill = true + break + end + end + end + + if canFill then + self[key] = self:castAttribute(key, value) + end + end +end + -- Get a query builder instance for this model's table function Model:query() + local builder = QueryBuilder:new(self.table) + local modelClass = getmetatable(self) + + -- Automatically exclude soft deleted records + if modelClass.softDelete then + builder:whereNull("deleted_at") + end + + return builder +end + +-- Query including soft deleted records +function Model:withTrashed() return QueryBuilder:new(self.table) end +-- Query only soft deleted records +function Model:onlyTrashed() + local builder = QueryBuilder:new(self.table) + builder:whereNotNull("deleted_at") + return builder +end + -- Find a record by ID function Model:find(id) local result = self:query():where("id", "=", id):limit(1):get() - if result and #result > 0 then - return self:new(result[1]) -- Ensure it's an instance of Model + if result and result:count() > 0 then + return self:new(result:first()) -- Ensure it's an instance of Model end return nil end -- Save: Insert if new, otherwise update function Model:save() + local modelClass = getmetatable(self) + + -- Fire before save event + if not self:fireEvent("beforeSave") then + return false + end + if self.id then -- Only update fields that exist in the table local data = {} @@ -45,11 +161,29 @@ function Model:save() data[key] = value end end - return self:update(data) + + -- Add updated_at if timestamps enabled + if modelClass.timestamps then + data.updated_at = self:timestamp() + self.updated_at = data.updated_at + end + + local success = self:query():where("id", "=", self.id):update(data) + if success then + self:fireEvent("afterSave") + end + return success else + -- Add timestamps for new records + if modelClass.timestamps then + self.created_at = self:timestamp() + self.updated_at = self:timestamp() + end + local id = self:query():insert(self:toTable()) if id then - self.id = id -- Assign ID to the instance + self.id = id + self:fireEvent("afterSave") return true end end @@ -58,37 +192,170 @@ end -- Get all records function Model:all() - local result = self:query():get() - return Collection:new(result) -- Return as Collection + return self:query():get() -- Already returns a Collection end function Model:get() - local result = self:query():get() - return Collection:new(result) -- Return as Collection + return self:query():get() -- Already returns a Collection end -- Insert a new record function Model:create(data) - local id = self:query():insert(data) - return self:find(id) -- Return the newly created record + local modelClass = getmetatable(self) + + -- Create a new instance to fire events + local instance = self:new(data) + + -- Fire before create event + if not instance:fireEvent("beforeCreate") then + return nil + end + + -- Add timestamps if enabled + if modelClass.timestamps then + data.created_at = instance:timestamp() + data.updated_at = instance:timestamp() + end + + local id = instance:query():insert(data) + + if id then + local created = self:find(id) + created:fireEvent("afterCreate") + return created + end + + return nil end -- Update an existing record function Model:update(data) if not self.id then error("Cannot update a record without an ID") end + + local modelClass = getmetatable(self) + + -- Fire before update event + if not self:fireEvent("beforeUpdate", data) then + return false + end + + -- Add updated_at timestamp if enabled + if modelClass.timestamps then + data.updated_at = self:timestamp() + end + local success = self:query():where("id", "=", self.id):update(data) + if success then for key, value in pairs(data) do - self[key] = value -- Update instance attributes + self[key] = self:castAttribute(key, value) end + self:fireEvent("afterUpdate") end + return success end -- Delete a record function Model:delete() if not self.id then error("Cannot delete a record without an ID") end - local success = self:query():where("id", "=", self.id):delete() + + local modelClass = getmetatable(self) + + -- Fire before delete event + if not self:fireEvent("beforeDelete") then + return false + end + + -- Soft delete if enabled + if modelClass.softDelete then + local data = { deleted_at = self:timestamp() } + local success = QueryBuilder:new(self.table):where("id", "=", self.id):update(data) + if success then + self.deleted_at = data.deleted_at + self:fireEvent("afterDelete") + end + return success + end + + -- Handle cascade deletes if defined + if modelClass.__cascadeDelete then + for _, relationName in ipairs(modelClass.__cascadeDelete) do + if self[relationName] and type(self[relationName]) == "function" then + local relatedRecords = self[relationName](self) + -- Check if it's a Collection or single record + if relatedRecords then + if type(relatedRecords.each) == "function" then + -- It's a Collection from hasMany + relatedRecords:each(function(record) + if record.delete then + record:delete() + end + end) + elseif type(relatedRecords.delete) == "function" then + -- It's a single record from hasOne + relatedRecords:delete() + end + end + end + end + end + + local success = QueryBuilder:new(self.table):where("id", "=", self.id):delete() + if success then + self:fireEvent("afterDelete") + end + return success +end + +-- Force delete (permanently delete even if soft delete enabled) +function Model:forceDelete() + if not self.id then error("Cannot delete a record without an ID") end + + local modelClass = getmetatable(self) + + -- Handle cascade deletes + if modelClass.__cascadeDelete then + for _, relationName in ipairs(modelClass.__cascadeDelete) do + if self[relationName] and type(self[relationName]) == "function" then + local relatedRecords = self[relationName](self) + if relatedRecords then + if type(relatedRecords.each) == "function" then + relatedRecords:each(function(record) + if record.forceDelete then + record:forceDelete() + elseif record.delete then + record:delete() + end + end) + elseif type(relatedRecords.delete) == "function" then + relatedRecords:forceDelete() + end + end + end + end + end + + return QueryBuilder:new(self.table):where("id", "=", self.id):delete() +end + +-- Restore a soft deleted record +function Model:restore() + if not self.id then error("Cannot restore a record without an ID") end + + local modelClass = getmetatable(self) + + if not modelClass.softDelete then + error("Cannot restore: soft delete is not enabled for this model") + end + + local data = { deleted_at = nil } + local success = QueryBuilder:new(self.table):where("id", "=", self.id):update(data) + + if success then + self.deleted_at = nil + end + return success end @@ -213,6 +480,101 @@ function Model:update_or_create(findData, updateData) end end +-- Query Scopes: Call scope methods dynamically +-- Define scopes as methods like: function User:scopeActive(query) return query:where("status", "=", "active") end +-- Use scopes like: User:active():get() +function Model:__index(key) + -- Check if it's a scope method + local modelClass = getmetatable(self) or self + local scope_method = "scope" .. key:sub(1, 1):upper() .. key:sub(2) + + if modelClass[scope_method] and type(modelClass[scope_method]) == "function" then + return function(...) + local query = self:query() + return modelClass[scope_method](self, query, ...) + end + end + + -- Fall back to normal behavior + return Model[key] or rawget(self, key) +end + +-- Simple validation system +Model.rules = {} -- Validation rules + +function Model:validate(data) + local modelClass = getmetatable(self) + + if not modelClass.rules or type(modelClass.rules) ~= "table" then + return true, {} + end + + local errors = {} + + for field, rules_str in pairs(modelClass.rules) do + local value = data[field] + local rules = {} + + -- Parse rules string (e.g., "required|min:3|max:255") + for rule in string.gmatch(rules_str, "[^|]+") do + table.insert(rules, rule) + end + + -- Check each rule + for _, rule in ipairs(rules) do + if rule == "required" then + if value == nil or value == "" then + table.insert(errors, field .. " is required") + end + elseif rule:match("^min:") then + local min_val = tonumber(rule:match("^min:(%d+)")) + if value and type(value) == "string" and #value < min_val then + table.insert(errors, field .. " must be at least " .. min_val .. " characters") + elseif value and type(value) == "number" and value < min_val then + table.insert(errors, field .. " must be at least " .. min_val) + end + elseif rule:match("^max:") then + local max_val = tonumber(rule:match("^max:(%d+)")) + if value and type(value) == "string" and #value > max_val then + table.insert(errors, field .. " must be at most " .. max_val .. " characters") + elseif value and type(value) == "number" and value > max_val then + table.insert(errors, field .. " must be at most " .. max_val) + end + elseif rule == "email" then + if value and not value:match("^[%w%._%+-]+@[%w%._%+-]+%.%w+$") then + table.insert(errors, field .. " must be a valid email address") + end + elseif rule == "numeric" then + if value and not tonumber(value) then + table.insert(errors, field .. " must be numeric") + end + elseif rule:match("^unique:") then + local table_name = rule:match("^unique:(%w+)") + if value then + local existing = QueryBuilder:new(table_name) + :where(field, "=", value) + :first() + if existing and (not self.id or existing.id ~= self.id) then + table.insert(errors, field .. " must be unique") + end + end + end + end + end + + return #errors == 0, errors +end + +-- Validate before creating +local original_create = Model.create +function Model:create(data) + local valid, errors = self:validate(data) + if not valid then + error("Validation failed: " .. table.concat(errors, ", ")) + end + return original_create(self, data) +end + -- **Use Relationships from `relationships.lua`** Model.hasOne = Relationships.hasOne Model.hasMany = Relationships.hasMany @@ -221,5 +583,9 @@ Model.belongsToMany = Relationships.belongsToMany Model.with = Relationships.with Model.cascadeDelete = Relationships.cascadeDelete Model.cascadeDeletePivot = Relationships.cascadeDeletePivot +Model.hasManyThrough = Relationships.hasManyThrough +Model.morphMany = Relationships.morphMany +Model.morphOne = Relationships.morphOne +Model.morphTo = Relationships.morphTo return Model \ No newline at end of file diff --git a/src/lumo/query_builder.lua b/src/lumo/query_builder.lua index c9112fc..270c9fd 100644 --- a/src/lumo/query_builder.lua +++ b/src/lumo/query_builder.lua @@ -1,3 +1,5 @@ +local Collection = require("lumo.collection") + local QueryBuilder = {} QueryBuilder.__index = QueryBuilder QueryBuilder.db = nil -- Will be set dynamically @@ -12,19 +14,154 @@ function QueryBuilder:new(table) local instance = setmetatable({}, self) instance.table = table instance.conditions = {} + instance.condition_operators = {} -- Track AND/OR between conditions instance.params = {} + instance.select_columns = nil -- Specific columns to select + instance.is_distinct = false + instance.join_clauses = {} + instance.group_by_columns = nil + instance.having_conditions = {} + instance.having_params = {} instance.order_by = nil instance.limit_count = nil + instance.offset_count = nil return instance end -- Add WHERE conditions function QueryBuilder:where(field, operator, value) + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end table.insert(self.conditions, field .. " " .. operator .. " ?") table.insert(self.params, value) return self end +-- Add OR WHERE conditions +function QueryBuilder:orWhere(field, operator, value) + if #self.conditions > 0 then + table.insert(self.condition_operators, "OR") + end + table.insert(self.conditions, field .. " " .. operator .. " ?") + table.insert(self.params, value) + return self +end + +-- Add WHERE IN condition +function QueryBuilder:whereIn(field, values) + if type(values) ~= "table" or #values == 0 then + error("whereIn requires a non-empty table of values") + end + + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end + + local placeholders = {} + for _, value in ipairs(values) do + table.insert(placeholders, "?") + table.insert(self.params, value) + end + + table.insert(self.conditions, field .. " IN (" .. table.concat(placeholders, ", ") .. ")") + return self +end + +-- Add WHERE NOT condition +function QueryBuilder:whereNot(field, operator, value) + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end + table.insert(self.conditions, "NOT (" .. field .. " " .. operator .. " ?)") + table.insert(self.params, value) + return self +end + +-- Add WHERE NULL condition +function QueryBuilder:whereNull(field) + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end + table.insert(self.conditions, field .. " IS NULL") + return self +end + +-- Add WHERE NOT NULL condition +function QueryBuilder:whereNotNull(field) + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end + table.insert(self.conditions, field .. " IS NOT NULL") + return self +end + +-- Add raw WHERE condition +function QueryBuilder:whereRaw(sql, ...) + if #self.conditions > 0 then + table.insert(self.condition_operators, "AND") + end + table.insert(self.conditions, sql) + for _, param in ipairs({...}) do + table.insert(self.params, param) + end + return self +end + +-- Select specific columns +function QueryBuilder:select(...) + local columns = {...} + if #columns == 0 then + self.select_columns = nil + else + self.select_columns = table.concat(columns, ", ") + end + return self +end + +-- Add DISTINCT +function QueryBuilder:distinct() + self.is_distinct = true + return self +end + +-- Add JOIN clause +function QueryBuilder:join(table, first, operator, second, join_type) + join_type = join_type or "INNER JOIN" + table.insert(self.join_clauses, { + type = join_type, + table = table, + first = first, + operator = operator, + second = second + }) + return self +end + +-- Add LEFT JOIN +function QueryBuilder:leftJoin(table, first, operator, second) + return self:join(table, first, operator, second, "LEFT JOIN") +end + +-- Add RIGHT JOIN +function QueryBuilder:rightJoin(table, first, operator, second) + return self:join(table, first, operator, second, "RIGHT JOIN") +end + +-- Add GROUP BY clause +function QueryBuilder:groupBy(...) + local columns = {...} + self.group_by_columns = table.concat(columns, ", ") + return self +end + +-- Add HAVING clause +function QueryBuilder:having(field, operator, value) + table.insert(self.having_conditions, field .. " " .. operator .. " ?") + table.insert(self.having_params, value) + return self +end + -- Add ORDER BY clause function QueryBuilder:orderBy(field, direction) self.order_by = "ORDER BY " .. field .. " " .. (direction or "ASC") @@ -37,23 +174,87 @@ function QueryBuilder:limit(count) return self end +-- Add OFFSET clause +function QueryBuilder:offset(count) + self.offset_count = "OFFSET " .. count + return self +end + +-- Build WHERE clause with proper AND/OR operators +function QueryBuilder:buildWhereClause() + if #self.conditions == 0 then + return "" + end + + local where_parts = {} + for i, condition in ipairs(self.conditions) do + if i == 1 then + table.insert(where_parts, condition) + else + local operator = self.condition_operators[i - 1] or "AND" + table.insert(where_parts, operator .. " " .. condition) + end + end + + return " WHERE " .. table.concat(where_parts, " ") +end + -- Execute the SELECT query function QueryBuilder:get() - local sql = "SELECT * FROM " .. self.table + -- Build SELECT clause + local select_part = "SELECT " + if self.is_distinct then + select_part = select_part .. "DISTINCT " + end + select_part = select_part .. (self.select_columns or "*") - if #self.conditions > 0 then - sql = sql .. " WHERE " .. table.concat(self.conditions, " AND ") + local sql = select_part .. " FROM " .. self.table + + -- Add JOINs + for _, join in ipairs(self.join_clauses) do + sql = sql .. " " .. join.type .. " " .. join.table .. + " ON " .. join.first .. " " .. join.operator .. " " .. join.second + end + + -- Add WHERE clause + sql = sql .. self:buildWhereClause() + + -- Add GROUP BY + if self.group_by_columns then + sql = sql .. " GROUP BY " .. self.group_by_columns end + -- Add HAVING + if #self.having_conditions > 0 then + sql = sql .. " HAVING " .. table.concat(self.having_conditions, " AND ") + end + + -- Add ORDER BY if self.order_by then sql = sql .. " " .. self.order_by end + -- Add LIMIT if self.limit_count then sql = sql .. " " .. self.limit_count end - return self.db:query(sql, table.unpack(self.params)) + -- Add OFFSET + if self.offset_count then + sql = sql .. " " .. self.offset_count + end + + -- Combine params with having params + local all_params = {} + for _, p in ipairs(self.params) do + table.insert(all_params, p) + end + for _, p in ipairs(self.having_params) do + table.insert(all_params, p) + end + + local results = self.db:query(sql, table.unpack(all_params)) + return Collection:new(results) end -- Get the first record @@ -102,11 +303,10 @@ function QueryBuilder:update(data) 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 + sql = sql .. self:buildWhereClause() + + for _, param in ipairs(self.params) do + table.insert(values, param) end return self.db:execute(sql, table.unpack(values)) @@ -120,11 +320,150 @@ function QueryBuilder:delete() local sql = "DELETE FROM " .. self.table - if #self.conditions > 0 then - sql = sql .. " WHERE " .. table.concat(self.conditions, " AND ") - end + sql = sql .. self:buildWhereClause() return self.db:execute(sql, table.unpack(self.params)) end +-- Aggregation functions +function QueryBuilder:count(column) + column = column or "*" + local original_select = self.select_columns + self:select("COUNT(" .. column .. ") as aggregate") + local result = self:get() + self.select_columns = original_select + return result[1] and result[1].aggregate or 0 +end + +function QueryBuilder:sum(column) + local original_select = self.select_columns + self:select("SUM(" .. column .. ") as aggregate") + local result = self:get() + self.select_columns = original_select + return result[1] and result[1].aggregate or 0 +end + +function QueryBuilder:avg(column) + local original_select = self.select_columns + self:select("AVG(" .. column .. ") as aggregate") + local result = self:get() + self.select_columns = original_select + return result[1] and result[1].aggregate or 0 +end + +function QueryBuilder:min(column) + local original_select = self.select_columns + self:select("MIN(" .. column .. ") as aggregate") + local result = self:get() + self.select_columns = original_select + return result[1] and result[1].aggregate or nil +end + +function QueryBuilder:max(column) + local original_select = self.select_columns + self:select("MAX(" .. column .. ") as aggregate") + local result = self:get() + self.select_columns = original_select + return result[1] and result[1].aggregate or nil +end + +-- Check if records exist +function QueryBuilder:exists() + local count = self:count() + return count > 0 +end + +-- Pagination helper - get page with per_page items +function QueryBuilder:forPage(page, per_page) + page = page or 1 + per_page = per_page or 15 + local offset_val = (page - 1) * per_page + return self:offset(offset_val):limit(per_page) +end + +-- Paginate results with metadata +function QueryBuilder:paginate(per_page, current_page) + per_page = per_page or 15 + current_page = current_page or 1 + + -- Get total count (before applying limit/offset) + local count_builder = QueryBuilder:new(self.table) + count_builder.conditions = self.conditions + count_builder.condition_operators = self.condition_operators + count_builder.params = self.params + count_builder.join_clauses = self.join_clauses + count_builder.group_by_columns = self.group_by_columns + count_builder.having_conditions = self.having_conditions + count_builder.having_params = self.having_params + + local total = count_builder:count() + local last_page = math.ceil(total / per_page) + + -- Get the actual page data + local data = self:forPage(current_page, per_page):get() + + return { + data = data, + total = total, + per_page = per_page, + current_page = current_page, + last_page = last_page, + from = ((current_page - 1) * per_page) + 1, + to = math.min(current_page * per_page, total) + } +end + +-- Process large datasets in chunks +function QueryBuilder:chunk(size, callback) + size = size or 100 + local page = 1 + + repeat + local results = self:forPage(page, size):get() + + if #results == 0 then + break + end + + callback(results, page) + page = page + 1 + until #results < size +end + +-- Bulk insert multiple records +function QueryBuilder:insertMany(records) + if type(records) ~= "table" or #records == 0 then + error("insertMany requires a non-empty table of records") + end + + -- Get column names from first record + local columns = {} + for column, _ in pairs(records[1]) do + table.insert(columns, column) + end + table.sort(columns) -- Ensure consistent order + + -- Build values for all records + local all_values = {} + local value_placeholders = {} + + for _, record in ipairs(records) do + local record_values = {} + for _, column in ipairs(columns) do + table.insert(all_values, record[column]) + table.insert(record_values, "?") + end + table.insert(value_placeholders, "(" .. table.concat(record_values, ", ") .. ")") + end + + local sql = string.format( + "INSERT INTO %s (%s) VALUES %s", + self.table, + table.concat(columns, ", "), + table.concat(value_placeholders, ", ") + ) + + return self.db:execute(sql, table.unpack(all_values)) +end + return QueryBuilder \ No newline at end of file diff --git a/src/lumo/relationships.lua b/src/lumo/relationships.lua index 980f439..a581b17 100644 --- a/src/lumo/relationships.lua +++ b/src/lumo/relationships.lua @@ -1,9 +1,10 @@ local QueryBuilder = require("lumo.query_builder") +local Collection = require("lumo.collection") local Relationships = {} Relationships.__index = Relationships --- One-to-One relationship (with eager loading) +-- One-to-One relationship function Relationships:hasOne(model, foreignKey, localKey) if not model or not model.query then error("Invalid model passed to hasOne()") @@ -12,10 +13,13 @@ function Relationships:hasOne(model, foreignKey, localKey) foreignKey = foreignKey or model.table .. "_id" local result = model:query():where(foreignKey, "=", self[localKey]):limit(1):get() - return #result > 0 and result[1] or nil + if result:count() > 0 then + return model:new(result:first()) + end + return nil end --- One-to-Many relationship (with eager loading) +-- One-to-Many relationship function Relationships:hasMany(model, foreignKey, localKey) if not model or not model.query then error("Invalid model passed to hasMany()") @@ -23,10 +27,15 @@ function Relationships:hasMany(model, foreignKey, localKey) localKey = localKey or "id" foreignKey = foreignKey or model.table .. "_id" - return model:query():where(foreignKey, "=", self[localKey]):get() + local results = model:query():where(foreignKey, "=", self[localKey]):get() + + -- Convert each result to a Model instance + return results:map(function(row) + return model:new(row) + end) end --- Belongs-To relationship (with eager loading) +-- Belongs-To relationship function Relationships:belongsTo(model, foreignKey, localKey) if not model or not model.query then error("Invalid model passed to belongsTo()") @@ -35,32 +44,38 @@ function Relationships:belongsTo(model, foreignKey, localKey) foreignKey = foreignKey or self.table .. "_id" local result = model:query():where(localKey, "=", self[foreignKey]):limit(1):get() - return #result > 0 and result[1] or nil + if result:count() > 0 then + return model:new(result:first()) + end + return nil end --- Many-to-Many relationship (via pivot table with eager loading) +-- Many-to-Many relationship (via pivot table) 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) + local pivotResults = QueryBuilder:new(pivotTable) :where(localKey, "=", self.id) :get() - local relatedIDs = {} - for _, row in ipairs(results) do - table.insert(relatedIDs, row[foreignKey]) - end + local relatedIDs = pivotResults:pluck(foreignKey):toArray() - if next(relatedIDs) then - return model:query():where("id", "IN", "(" .. table.concat(relatedIDs, ",") .. ")"):get() + if #relatedIDs > 0 then + local results = model:query():whereIn("id", relatedIDs):get() + + -- Convert each result to a Model instance + return results:map(function(row) + return model:new(row) + end) end - return {} + + return Collection:new({}) end --- Eager load relationships to avoid N+1 queries +-- Load relationships (note: this still causes N+1 queries for single models, not true eager loading for collections) function Relationships:with(relations) if type(relations) ~= "table" then relations = { relations } -- Allow single or multiple relationships @@ -75,15 +90,57 @@ function Relationships:with(relations) return loaded end +-- Eager load relationships for a collection (reduces N+1 queries) +function Relationships.eagerLoad(collection, relations) + if type(relations) == "string" then + relations = { relations } + end + + if not collection or #collection == 0 then + return collection + end + + for _, relation_name in ipairs(relations) do + -- Get the model class from the first item + local first_model = collection[1] + local modelClass = getmetatable(first_model) + + -- Collect all IDs + local ids = {} + for _, model in ipairs(collection) do + if model.id then + table.insert(ids, model.id) + end + end + + if #ids == 0 then + goto continue + end + + -- Check what type of relationship this is by checking if the method exists + -- This is a simplified implementation - in practice, you'd need metadata about relationship types + -- For now, we'll just load the relationship for each model + for _, model in ipairs(collection) do + if model[relation_name] and type(model[relation_name]) == "function" then + model["_" .. relation_name] = model[relation_name](model) + end + end + + ::continue:: + end + + return collection +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 + relatedRecords:each(function(record) record:delete() - end + end) end -- Cascade delete for Many-to-Many (remove pivot entries) @@ -95,4 +152,105 @@ function Relationships:cascadeDeletePivot(pivotTable, localKey) end end +-- Has Many Through relationship (indirect relationship through another model) +-- Example: Country -> User -> Post (Country has many Posts through Users) +function Relationships:hasManyThrough(finalModel, throughModel, firstKey, secondKey, localKey, secondLocalKey) + if not finalModel or not finalModel.query then + error("Invalid final model passed to hasManyThrough()") + end + if not throughModel or not throughModel.query then + error("Invalid through model passed to hasManyThrough()") + end + + localKey = localKey or "id" + firstKey = firstKey or self.table .. "_id" + secondLocalKey = secondLocalKey or "id" + secondKey = secondKey or throughModel.table .. "_id" + + -- Get IDs from the through model + local through_records = QueryBuilder:new(throughModel.table) + :where(firstKey, "=", self[localKey]) + :get() + + if #through_records == 0 then + return Collection:new({}) + end + + local through_ids = through_records:pluck(secondLocalKey):toArray() + + -- Get final models using the through IDs + local results = finalModel:query() + :whereIn(secondKey, through_ids) + :get() + + return results:map(function(row) + return finalModel:new(row) + end) +end + +-- Polymorphic One-to-Many (morph many) +-- Example: A Comment can belong to either a Post or a Video +function Relationships:morphMany(model, name, type_field, id_field) + if not model or not model.query then + error("Invalid model passed to morphMany()") + end + + type_field = type_field or name .. "_type" + id_field = id_field or name .. "_id" + + local results = model:query() + :where(type_field, "=", self.table) + :where(id_field, "=", self.id) + :get() + + return results:map(function(row) + return model:new(row) + end) +end + +-- Polymorphic Belongs To (morph to) +-- Example: Comment morphs to either Post or Video +function Relationships:morphTo(name, type_field, id_field) + type_field = type_field or name .. "_type" + id_field = id_field or name .. "_id" + + local related_type = self[type_field] + local related_id = self[id_field] + + if not related_type or not related_id then + return nil + end + + -- You'll need to register models to resolve the type string + -- For now, we'll return the type and ID + -- In practice, you'd do: local Model = _G[related_type] or require("models." .. related_type:lower()) + return { + type = related_type, + id = related_id, + -- In a real implementation, you'd fetch and return the actual model instance + } +end + +-- Polymorphic One-to-One (morph one) +function Relationships:morphOne(model, name, type_field, id_field) + if not model or not model.query then + error("Invalid model passed to morphOne()") + end + + type_field = type_field or name .. "_type" + id_field = id_field or name .. "_id" + + local result = model:query() + :where(type_field, "=", self.table) + :where(id_field, "=", self.id) + :limit(1) + :get() + + if result:count() > 0 then + return model:new(result:first()) + end + + return nil +end + return Relationships \ No newline at end of file diff --git a/src/lumo/seeder.lua b/src/lumo/seeder.lua new file mode 100644 index 0000000..0af5f4f --- /dev/null +++ b/src/lumo/seeder.lua @@ -0,0 +1,98 @@ +local Seeder = {} +Seeder.__index = Seeder + +-- Registry of seeders +Seeder.seeders = {} + +-- Register a seeder function +function Seeder.register(name, seedFn) + if type(seedFn) ~= "function" then + error("Seeder must be a function") + end + Seeder.seeders[name] = seedFn +end + +-- Run all registered seeders +function Seeder:run() + print("Running database seeders...") + + for name, seedFn in pairs(Seeder.seeders) do + print(" Running seeder: " .. name) + local success, err = pcall(seedFn) + if not success then + print(" [ERROR] Seeder '" .. name .. "' failed: " .. tostring(err)) + else + print(" [OK] Seeder '" .. name .. "' completed successfully") + end + end + + print("Seeding complete!") +end + +-- Run a specific seeder by name +function Seeder:runSeeder(name) + if not Seeder.seeders[name] then + error("Seeder '" .. name .. "' not found") + end + + print("Running seeder: " .. name) + local success, err = pcall(Seeder.seeders[name]) + if not success then + print("[ERROR] Seeder failed: " .. tostring(err)) + return false + end + + print("[OK] Seeder completed successfully") + return true +end + +-- Helper: Generate fake data +Seeder.fake = { + -- Generate a random name + name = function() + local first_names = {"Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry"} + local last_names = {"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis"} + return first_names[math.random(#first_names)] .. " " .. last_names[math.random(#last_names)] + end, + + -- Generate a random email + email = function() + local names = {"john", "jane", "alice", "bob", "charlie", "diana"} + local domains = {"example.com", "test.com", "demo.com", "sample.org"} + return names[math.random(#names)] .. math.random(100, 999) .. "@" .. domains[math.random(#domains)] + end, + + -- Generate a random number between min and max + number = function(min, max) + min = min or 1 + max = max or 100 + return math.random(min, max) + end, + + -- Generate a random boolean + boolean = function() + return math.random() > 0.5 + end, + + -- Generate lorem ipsum text + text = function(words) + words = words or 10 + local lorem = {"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", + "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore"} + local result = {} + for i = 1, words do + table.insert(result, lorem[math.random(#lorem)]) + end + return table.concat(result, " ") + end, + + -- Pick a random element from a table + choice = function(options) + if type(options) ~= "table" or #options == 0 then + error("choice() requires a non-empty table") + end + return options[math.random(#options)] + end +} + +return Seeder