From 15c6094de7c0de1fe9ee2eff2e30f331b8736532 Mon Sep 17 00:00:00 2001 From: zhongqin Date: Tue, 19 May 2026 02:20:48 +0800 Subject: [PATCH] add database crud operation function to plugin system --- .../content_types/ecommerce_cart_item.toml | 49 - extensions/content_types/ecommerce_order.toml | 79 - .../content_types/ecommerce_order_item.toml | 70 - .../content_types/ecommerce_product.toml | 99 -- .../ecommerce_product_category.toml | 68 - extensions/plugins/crm/main.js | 255 ++- extensions/plugins/crm/sdk.d.ts | 20 + extensions/plugins/ecommerce/jsconfig.json | 13 - extensions/plugins/ecommerce/main.js | 200 --- extensions/plugins/ecommerce/manifest.toml | 79 - extensions/plugins/ecommerce/sdk.d.ts | 43 - extensions/plugins/first-ext/init.lua | 41 +- extensions/plugins/first-ext/sdk.d.ts | 20 + extensions/plugins/forum/main.js | 204 ++- extensions/plugins/forum/sdk.d.ts | 20 + plugin-sdk/js/js_plugin_v1.js | 64 + plugin-sdk/lua/lua_plugin_v1.lua | 73 + src/models/cart_item.rs | 22 +- src/plugins/host_common.rs | 1542 +++++++++++++++++ src/plugins/js_host.rs | 79 + src/plugins/lua_host.rs | 88 + src/plugins/rhai_host.rs | 75 + 22 files changed, 2201 insertions(+), 1002 deletions(-) delete mode 100644 extensions/content_types/ecommerce_cart_item.toml delete mode 100644 extensions/content_types/ecommerce_order.toml delete mode 100644 extensions/content_types/ecommerce_order_item.toml delete mode 100644 extensions/content_types/ecommerce_product.toml delete mode 100644 extensions/content_types/ecommerce_product_category.toml delete mode 100644 extensions/plugins/ecommerce/jsconfig.json delete mode 100644 extensions/plugins/ecommerce/main.js delete mode 100644 extensions/plugins/ecommerce/manifest.toml delete mode 100644 extensions/plugins/ecommerce/sdk.d.ts diff --git a/extensions/content_types/ecommerce_cart_item.toml b/extensions/content_types/ecommerce_cart_item.toml deleted file mode 100644 index 13c012a8..00000000 --- a/extensions/content_types/ecommerce_cart_item.toml +++ /dev/null @@ -1,49 +0,0 @@ -[content_type] -name = "Cart Item" -singular = "cart_item" -plural = "cart_items" -table = "cart_items" -description = "购物车项" -implements = ["ownable", "timestampable"] - -[fields.user_id] -type = "text" -required = true -label = "用户ID" - -[fields.product_id] -type = "text" -required = true -label = "商品ID" - -[fields.quantity] -type = "integer" -required = true -min = 1 -default = 1 -label = "数量" - -[[indexes]] -fields = ["user_id", "product_id"] -unique = true - -[list_view] -default_sort = "created_at:desc" -columns = ["user_id", "product_id", "quantity", "created_at"] - -[api.list] -access = "admin" -cache = true - -[api.get] -access = "admin" -cache = true - -[api.create] -access = "admin" - -[api.update] -access = "admin" - -[api.delete] -access = "admin" diff --git a/extensions/content_types/ecommerce_order.toml b/extensions/content_types/ecommerce_order.toml deleted file mode 100644 index 917908f2..00000000 --- a/extensions/content_types/ecommerce_order.toml +++ /dev/null @@ -1,79 +0,0 @@ -[content_type] -name = "Order" -singular = "order" -plural = "orders" -table = "orders" -description = "订单" -implements = ["ownable", "timestampable"] - -[fields.order_no] -type = "text" -required = true -unique = true -max_length = 32 -immutable = true -label = "订单号" - -[fields.user_id] -type = "text" -required = true -immutable = true -label = "用户ID" - -[fields.status] -type = "enum" -enum_values = ["pending", "paid", "shipped", "completed", "cancelled", "refunded"] -default = "pending" -label = "订单状态" - -[fields.total_amount] -type = "decimal" -required = true -min = 0 -label = "订单总额" - -[fields.shipping_address] -type = "json" -label = "收货地址" - -[fields.note] -type = "text" -max_length = 500 -label = "备注" - -[fields.paid_at] -type = "datetime" -label = "支付时间" - -[fields.shipped_at] -type = "datetime" -label = "发货时间" - -[[indexes]] -fields = ["order_no"] -unique = true - -[[indexes]] -fields = ["user_id", "created_at"] - -[list_view] -default_sort = "created_at:desc" -columns = ["order_no", "user_id", "status", "total_amount", "created_at"] - -[api.list] -access = "admin" -cache = true -filter_auth = 'user_id = @request.auth.id' - -[api.get] -access = "admin" -cache = true - -[api.create] -access = "admin" - -[api.update] -access = "admin" - -[api.delete] -access = "admin" diff --git a/extensions/content_types/ecommerce_order_item.toml b/extensions/content_types/ecommerce_order_item.toml deleted file mode 100644 index 316966bf..00000000 --- a/extensions/content_types/ecommerce_order_item.toml +++ /dev/null @@ -1,70 +0,0 @@ -[content_type] -name = "Order Item" -singular = "order_item" -plural = "order_items" -table = "order_items" -description = "订单项" -implements = ["ownable", "timestampable"] - -[fields.order_id] -type = "relation" -relation_type = "many_to_one" -target = "orders" -foreign_key = "order_id" -required = true -immutable = true -label = "订单" - -[fields.product_id] -type = "text" -required = true -immutable = true -label = "商品ID" - -[fields.product_name] -type = "text" -required = true -max_length = 200 -immutable = true -label = "商品名称" - -[fields.price] -type = "decimal" -required = true -min = 0 -immutable = true -label = "单价" - -[fields.quantity] -type = "integer" -required = true -min = 1 -immutable = true -label = "数量" - -[fields.subtotal] -type = "decimal" -required = true -min = 0 -immutable = true -label = "小计" - -[[indexes]] -fields = ["order_id"] - -[api.list] -access = "admin" -cache = true - -[api.get] -access = "admin" -cache = true - -[api.create] -access = "admin" - -[api.update] -access = "admin" - -[api.delete] -access = "admin" diff --git a/extensions/content_types/ecommerce_product.toml b/extensions/content_types/ecommerce_product.toml deleted file mode 100644 index e851ada3..00000000 --- a/extensions/content_types/ecommerce_product.toml +++ /dev/null @@ -1,99 +0,0 @@ -[content_type] -name = "Product" -singular = "product" -plural = "products" -table = "products" -description = "商品" -implements = ["ownable", "timestampable"] -slug_field = "name" - -[fields.name] -type = "text" -required = true -max_length = 200 -label = "商品名称" - -[fields.slug] -type = "uid" -target_field = "name" -unique = true -label = "URL 标识" - -[fields.description] -type = "richtext" -label = "商品描述" - -[fields.price] -type = "decimal" -required = true -min = 0 -label = "价格" - -[fields.compare_at_price] -type = "decimal" -min = 0 -label = "划线价" - -[fields.stock] -type = "integer" -required = true -min = 0 -default = 0 -label = "库存" - -[fields.sku] -type = "text" -unique = true -max_length = 50 -label = "SKU" - -[fields.images] -type = "media" -accept = ["image/*"] -max_count = 10 -label = "商品图片" - -[fields.category] -type = "relation" -relation_type = "many_to_one" -target = "product_categories" -foreign_key = "category_id" -label = "分类" - -[fields.featured] -type = "boolean" -default = false -label = "推荐商品" - -[fields.weight] -type = "decimal" -min = 0 -label = "重量(kg)" - -[[indexes]] -fields = ["slug"] -unique = true - -[[indexes]] -fields = ["status", "created_at"] - -[list_view] -default_sort = "created_at:desc" -columns = ["name", "price", "stock", "status", "featured", "created_at"] - -[api.list] -access = "public" -cache = true - -[api.get] -access = "public" -cache = true - -[api.create] -access = "admin" - -[api.update] -access = "admin" - -[api.delete] -access = "admin" diff --git a/extensions/content_types/ecommerce_product_category.toml b/extensions/content_types/ecommerce_product_category.toml deleted file mode 100644 index ef26c799..00000000 --- a/extensions/content_types/ecommerce_product_category.toml +++ /dev/null @@ -1,68 +0,0 @@ -[content_type] -name = "Product Category" -singular = "product_category" -plural = "product_categories" -table = "product_categories" -description = "商品分类" -implements = ["ownable", "timestampable"] -slug_field = "name" - -[fields.name] -type = "text" -required = true -max_length = 100 -label = "分类名称" - -[fields.slug] -type = "uid" -target_field = "name" -unique = true -label = "URL 标识" - -[fields.description] -type = "text" -max_length = 500 -label = "描述" - -[fields.parent] -type = "relation" -relation_type = "many_to_one" -target = "product_categories" -foreign_key = "parent_id" -label = "父分类" - -[fields.sort_order] -type = "integer" -default = 0 -label = "排序" - -[fields.image] -type = "media" -accept = ["image/*"] -max_count = 1 -label = "分类图片" - -[[indexes]] -fields = ["slug"] -unique = true - -[list_view] -default_sort = "sort_order:asc,created_at:desc" -columns = ["name", "slug", "sort_order", "created_at"] - -[api.list] -access = "public" -cache = true - -[api.get] -access = "public" -cache = true - -[api.create] -access = "admin" - -[api.update] -access = "admin" - -[api.delete] -access = "admin" diff --git a/extensions/plugins/crm/main.js b/extensions/plugins/crm/main.js index 6cff7ca0..f08b2f26 100644 --- a/extensions/plugins/crm/main.js +++ b/extensions/plugins/crm/main.js @@ -1,4 +1,6 @@ -import { dbQuery, dbExec, ok, fail, extractJson, logInfo, eventEmit, newId } from 'sdk'; +import { dbQuery, dbExec, ok, fail, extractJson, logInfo, eventEmit, newId, + dbInsert, dbFetchOne, dbFetchAll, dbUpdate, dbDelete, dbCount, + dbIncrement, dbSum, dbGroupBy } from 'sdk'; const nowISO = () => new Date().toISOString(); @@ -67,18 +69,9 @@ export function getPipeline(input) { const pipeline = []; for (const stage of stages) { - const rows = dbQuery( - `SELECT id, title, amount, currency, probability, contact_id, company_id, owner_id, close_date - FROM crm_deals WHERE stage = ? ORDER BY amount DESC`, - [stage] - ); - const totalResult = dbQuery( - `SELECT CAST(COALESCE(SUM(amount), 0) AS TEXT) as total, CAST(COUNT(*) AS TEXT) as cnt - FROM crm_deals WHERE stage = ?`, - [stage] - ); - const total = totalResult?.[0] ? parseFloat(totalResult[0].total) : 0; - const count = totalResult?.[0] ? parseInt(totalResult[0].cnt, 10) : 0; + const rows = dbFetchAll("crm_deals", { stage }, { order_by: "amount DESC" }).data || []; + const total = dbSum("crm_deals", "amount", { stage }); + const count = dbCount("crm_deals", { stage }); const weighted = total * (stage === "closed_won" ? 100 : stage === "closed_lost" ? 0 : 30) / 100; pipeline.push({ @@ -87,7 +80,7 @@ export function getPipeline(input) { count, total_value: total, weighted_value: weighted, - deals: rows || [], + deals: rows, }); } @@ -110,36 +103,21 @@ export function getDealDetail(input) { const dealId = extractJson(input, "params.dealId"); if (!dealId) return fail(400, "deal id required"); - const deals = dbQuery( - `SELECT id, title, amount, currency, stage, probability, - contact_id, company_id, owner_id, close_date, loss_reason, description, - created_at, updated_at - FROM crm_deals WHERE id = ?`, - [dealId] - ); - if (!deals || deals.length === 0) return fail(404, "deal not found"); + const deal = dbFetchOne("crm_deals", { id: dealId }); + if (!deal) return fail(404, "deal not found"); - const deal = deals[0]; deal.amount = parseFloat(deal.amount) || 0; deal.probability = parseIntOrNull(deal.probability) || 0; - const activities = dbQuery( - `SELECT id, type, subject, activity_date, outcome, duration_minutes, owner_id, created_at - FROM crm_activities WHERE deal_id = ? ORDER BY activity_date DESC LIMIT 20`, - [dealId] - ); - for (const a of (activities || [])) { + const activities = dbFetchAll("crm_activities", { deal_id: dealId }, { order_by: "activity_date DESC", limit: 20 }).data || []; + for (const a of activities) { a.duration_minutes = parseIntOrNull(a.duration_minutes); } - const notes = dbQuery( - `SELECT id, content, pinned, owner_id, created_at - FROM crm_notes WHERE deal_id = ? ORDER BY pinned DESC, created_at DESC LIMIT 20`, - [dealId] - ); + const notes = dbFetchAll("crm_notes", { deal_id: dealId }, { order_by: "pinned DESC, created_at DESC", limit: 20 }).data || []; - deal.activities = activities || []; - deal.notes = notes || []; + deal.activities = activities; + deal.notes = notes; return ok(deal); } @@ -161,49 +139,40 @@ export function updateDealStage(input) { const validStages = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"]; if (!validStages.includes(newStage)) return fail(400, `invalid stage: ${newStage}`); - const deals = dbQuery("SELECT id, stage, title FROM crm_deals WHERE id = ?", [dealId]); - if (!deals || deals.length === 0) return fail(404, "deal not found"); + const deal = dbFetchOne("crm_deals", { id: dealId }); + if (!deal) return fail(404, "deal not found"); - const oldStage = deals[0].stage; + const oldStage = deal.stage; if (oldStage === newStage) return fail(400, "deal already in this stage"); const now = nowISO(); - const updates = ["stage = ?", "updated_at = ?"]; - const params = [newStage, now]; + const setData = { stage: newStage, updated_at: now }; if (probability != null) { - updates.push("probability = ?"); - params.push(String(probability)); + setData.probability = String(probability); } if (newStage === "closed_won") { - updates.push("probability = ?"); - params.push("100"); + setData.probability = "100"; } if (newStage === "closed_lost") { - updates.push("probability = ?"); - params.push("0"); + setData.probability = "0"; if (lossReason) { - updates.push("loss_reason = ?"); - params.push(lossReason); + setData.loss_reason = lossReason; } } - params.push(dealId); - const r = dbExec(`UPDATE crm_deals SET ${updates.join(", ")} WHERE id = ?`, params); + const r = dbUpdate("crm_deals", setData, { id: dealId }); if (r.error) return fail(500, r.error); const activityId = newId(); const activityContent = JSON.stringify({ old_stage: oldStage, new_stage: newStage }); - dbExec( - "INSERT INTO crm_activities (id, tenant_id, type, subject, content, deal_id, owner_id, activity_date, created_at, updated_at) VALUES (?, 'default', 'note', ?, ?, ?, ?, ?, ?, ?)", - [activityId, `Stage: ${oldStage} → ${newStage}`, activityContent, dealId, userId, now, now, now] - ); + dbInsert("crm_activities", { id: activityId, tenant_id: "default", type: "note", subject: `Stage: ${oldStage} → ${newStage}`, content: activityContent, deal_id: dealId, owner_id: userId, activity_date: now, created_at: now, updated_at: now }); eventEmit("crm.deal_stage_changed", JSON.stringify({ deal_id: dealId, - deal_title: deals[0].title, + deal_title: deal.title, old_stage: oldStage, new_stage: newStage, changed_by: userId, @@ -223,28 +192,19 @@ export function getContactTimeline(input) { const contactId = extractJson(input, "params.contactId"); if (!contactId) return fail(400, "contact id required"); - const contacts = dbQuery("SELECT id FROM crm_contacts WHERE id = ?", [contactId]); - if (!contacts || contacts.length === 0) return fail(404, "contact not found"); + const contact = dbFetchOne("crm_contacts", { id: contactId }); + if (!contact) return fail(404, "contact not found"); - const activities = dbQuery( - `SELECT 'activity' as type, id, type as activity_type, subject, content, activity_date, outcome, duration_minutes, owner_id, created_at - FROM crm_activities WHERE contact_id = ? ORDER BY activity_date DESC LIMIT 50`, - [contactId] - ); - - const notes = dbQuery( - `SELECT 'note' as type, id, content, pinned, owner_id, created_at - FROM crm_notes WHERE contact_id = ? ORDER BY created_at DESC LIMIT 50`, - [contactId] - ); + const rawActivities = dbFetchAll("crm_activities", { contact_id: contactId }, { order_by: "activity_date DESC", limit: 50 }).data || []; + const rawNotes = dbFetchAll("crm_notes", { contact_id: contactId }, { order_by: "created_at DESC", limit: 50 }).data || []; const timeline = []; - for (const a of (activities || [])) { + for (const a of rawActivities) { a.duration_minutes = parseIntOrNull(a.duration_minutes); - timeline.push({ ...a, timestamp: a.activity_date || a.created_at }); + timeline.push({ ...a, activity_type: a.type, type: 'activity', timestamp: a.activity_date || a.created_at }); } - for (const n of (notes || [])) { - timeline.push({ ...n, activity_type: "note", timestamp: n.created_at }); + for (const n of rawNotes) { + timeline.push({ ...n, activity_type: "note", type: 'note', timestamp: n.created_at }); } timeline.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || "")); @@ -258,37 +218,23 @@ export function getCompanyTimeline(input) { const companyId = extractJson(input, "params.companyId"); if (!companyId) return fail(400, "company id required"); - const companies = dbQuery("SELECT id FROM crm_companies WHERE id = ?", [companyId]); - if (!companies || companies.length === 0) return fail(404, "company not found"); + const company = dbFetchOne("crm_companies", { id: companyId }); + if (!company) return fail(404, "company not found"); - const activities = dbQuery( - `SELECT 'activity' as type, id, type as activity_type, subject, content, activity_date, outcome, owner_id, created_at - FROM crm_activities WHERE company_id = ? ORDER BY activity_date DESC LIMIT 50`, - [companyId] - ); - - const notes = dbQuery( - `SELECT 'note' as type, id, content, pinned, owner_id, created_at - FROM crm_notes WHERE company_id = ? ORDER BY created_at DESC LIMIT 50`, - [companyId] - ); - - const deals = dbQuery( - `SELECT 'deal' as type, id, title, stage, amount, currency, close_date, created_at - FROM crm_deals WHERE company_id = ? ORDER BY created_at DESC LIMIT 20`, - [companyId] - ); + const rawActivities = dbFetchAll("crm_activities", { company_id: companyId }, { order_by: "activity_date DESC", limit: 50 }).data || []; + const rawNotes = dbFetchAll("crm_notes", { company_id: companyId }, { order_by: "created_at DESC", limit: 50 }).data || []; + const rawDeals = dbFetchAll("crm_deals", { company_id: companyId }, { order_by: "created_at DESC", limit: 20 }).data || []; const timeline = []; - for (const a of (activities || [])) { - timeline.push({ ...a, timestamp: a.activity_date || a.created_at }); + for (const a of rawActivities) { + timeline.push({ ...a, activity_type: a.type, type: 'activity', timestamp: a.activity_date || a.created_at }); } - for (const n of (notes || [])) { - timeline.push({ ...n, activity_type: "note", timestamp: n.created_at }); + for (const n of rawNotes) { + timeline.push({ ...n, activity_type: "note", type: 'note', timestamp: n.created_at }); } - for (const d of (deals || [])) { + for (const d of rawDeals) { d.amount = parseFloat(d.amount) || 0; - timeline.push({ ...d, activity_type: "deal", timestamp: d.created_at }); + timeline.push({ ...d, activity_type: "deal", type: 'deal', timestamp: d.created_at }); } timeline.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || "")); @@ -299,25 +245,17 @@ export function getCompanyTimeline(input) { // ── GET /stats ─────────────────────────────────────────────── export function getDashboardStats() { - const contactCount = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM crm_contacts"); - const totalContacts = contactCount?.[0] ? parseInt(contactCount[0].cnt, 10) : 0; + const totalContacts = dbCount("crm_contacts"); + const activeCount = dbCount("crm_contacts", { status: "active" }); + const totalCompanies = dbCount("crm_companies"); - const activeContacts = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM crm_contacts WHERE status = 'active'"); - const activeCount = activeContacts?.[0] ? parseInt(activeContacts[0].cnt, 10) : 0; + const openDealCount = dbCount("crm_deals", "stage NOT IN ('closed_won', 'closed_lost')"); + const openDealValue = dbSum("crm_deals", "amount", "stage NOT IN ('closed_won', 'closed_lost')"); - const companyCount = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM crm_companies"); - const totalCompanies = companyCount?.[0] ? parseInt(companyCount[0].cnt, 10) : 0; + const wonDealCount = dbCount("crm_deals", { stage: "closed_won" }); + const wonDealValue = dbSum("crm_deals", "amount", { stage: "closed_won" }); - const openDeals = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt, CAST(COALESCE(SUM(amount), 0) AS TEXT) as total FROM crm_deals WHERE stage NOT IN ('closed_won', 'closed_lost')"); - const openDealCount = openDeals?.[0] ? parseInt(openDeals[0].cnt, 10) : 0; - const openDealValue = openDeals?.[0] ? parseFloat(openDeals[0].total) : 0; - - const wonDeals = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt, CAST(COALESCE(SUM(amount), 0) AS TEXT) as total FROM crm_deals WHERE stage = 'closed_won'"); - const wonDealCount = wonDeals?.[0] ? parseInt(wonDeals[0].cnt, 10) : 0; - const wonDealValue = wonDeals?.[0] ? parseFloat(wonDeals[0].total) : 0; - - const lostDeals = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM crm_deals WHERE stage = 'closed_lost'"); - const lostDealCount = lostDeals?.[0] ? parseInt(lostDeals[0].cnt, 10) : 0; + const lostDealCount = dbCount("crm_deals", { stage: "closed_lost" }); const winRate = (wonDealCount + lostDealCount) > 0 ? Math.round((wonDealCount / (wonDealCount + lostDealCount)) * 100) @@ -330,11 +268,13 @@ export function getDashboardStats() { ); const weeklyActivities = activityThisWeek?.[0] ? parseInt(activityThisWeek[0].cnt, 10) : 0; - const contactsByStage = dbQuery( - `SELECT lifecycle_stage, CAST(COUNT(*) AS TEXT) as cnt FROM crm_contacts GROUP BY lifecycle_stage ORDER BY cnt DESC` - ); + const lifecycleResult = dbGroupBy("crm_contacts", { + group_by: "lifecycle_stage", + count: true, + order_by: "cnt DESC" + }); const lifecycleDistribution = {}; - for (const row of (contactsByStage || [])) { + for (const row of (lifecycleResult.data || [])) { lifecycleDistribution[row.lifecycle_stage] = parseInt(row.cnt, 10); } @@ -358,31 +298,37 @@ export function getDashboardStats() { // ── GET /leaderboard ───────────────────────────────────────── export function getLeaderboard() { - const byDealValue = dbQuery( - `SELECT owner_id, CAST(COUNT(*) AS TEXT) as deal_count, CAST(COALESCE(SUM(amount), 0) AS TEXT) as total_value - FROM crm_deals WHERE stage = 'closed_won' GROUP BY owner_id ORDER BY total_value DESC LIMIT 10` - ); + const dealResult = dbGroupBy("crm_deals", { + group_by: "owner_id", + count: true, + sum: "amount", + where: { stage: "closed_won" }, + order_by: "sum_amount DESC", + limit: 10 + }); - const byActivity = dbQuery( - `SELECT owner_id, CAST(COUNT(*) AS TEXT) as activity_count - FROM crm_activities WHERE activity_date >= date('now', '-30 days') - GROUP BY owner_id ORDER BY activity_count DESC LIMIT 10` - ); + const activityResult = dbGroupBy("crm_activities", { + group_by: "owner_id", + count: true, + where: "activity_date >= date('now', '-30 days')", + order_by: "cnt DESC", + limit: 10 + }); const leaderboard = []; - for (const row of (byDealValue || [])) { + for (const row of (dealResult.data || [])) { leaderboard.push({ owner_id: row.owner_id, - won_deals: parseInt(row.deal_count, 10), - won_value: parseFloat(row.total_value), + won_deals: parseInt(row.cnt, 10), + won_value: parseFloat(row.sum_amount), }); } const activityLeaderboard = []; - for (const row of (byActivity || [])) { + for (const row of (activityResult.data || [])) { activityLeaderboard.push({ owner_id: row.owner_id, - activities: parseInt(row.activity_count, 10), + activities: parseInt(row.cnt, 10), }); } @@ -401,10 +347,9 @@ export function convertLead(input) { if (!userId) return fail(400, "user_id required"); if (!contactId) return fail(400, "contact id required"); - const contacts = dbQuery("SELECT id, lifecycle_stage, company_id FROM crm_contacts WHERE id = ?", [contactId]); - if (!contacts || contacts.length === 0) return fail(404, "contact not found"); + const contact = dbFetchOne("crm_contacts", { id: contactId }); + if (!contact) return fail(404, "contact not found"); - const contact = contacts[0]; const currentStage = contact.lifecycle_stage; const stageOrder = ["subscriber", "lead", "marketing_qualified_lead", "sales_qualified_lead", "opportunity", "customer"]; @@ -417,17 +362,11 @@ export function convertLead(input) { if (targetIdx <= currentIdx) return fail(400, `cannot convert backward from ${currentStage} to ${targetStage}`); const now = nowISO(); - const r = dbExec( - "UPDATE crm_contacts SET lifecycle_stage = ?, updated_at = ? WHERE id = ?", - [targetStage, now, contactId] - ); + const r = dbUpdate("crm_contacts", { lifecycle_stage: targetStage, updated_at: now }, { id: contactId }); if (r.error) return fail(500, r.error); const activityId = newId(); - dbExec( - "INSERT INTO crm_activities (id, tenant_id, type, subject, content, contact_id, company_id, owner_id, activity_date, created_at, updated_at) VALUES (?, 'default', 'note', ?, ?, ?, ?, ?, ?, ?, ?)", - [activityId, `Lifecycle: ${currentStage} → ${targetStage}`, JSON.stringify({ from: currentStage, to: targetStage }), contactId, contact.company_id || null, userId, now, now, now] - ); + dbInsert("crm_activities", { id: activityId, tenant_id: "default", type: "note", subject: `Lifecycle: ${currentStage} → ${targetStage}`, content: JSON.stringify({ from: currentStage, to: targetStage }), contact_id: contactId, company_id: contact.company_id || null, owner_id: userId, activity_date: now, created_at: now, updated_at: now }); eventEmit("crm.contact_converted", JSON.stringify({ contact_id: contactId, @@ -459,13 +398,8 @@ export function getFunnelReport() { const funnel = []; let prevCount = null; for (const stage of stages) { - const result = dbQuery( - `SELECT CAST(COUNT(*) AS TEXT) as cnt, CAST(COALESCE(SUM(amount), 0) AS TEXT) as total - FROM crm_deals WHERE stage = ?`, - [stage] - ); - const count = result?.[0] ? parseInt(result[0].cnt, 10) : 0; - const total = result?.[0] ? parseFloat(result[0].total) : 0; + const count = dbCount("crm_deals", { stage }); + const total = dbSum("crm_deals", "amount", { stage }); const conversionRate = prevCount != null && prevCount > 0 ? Math.round((count / prevCount) * 100) @@ -498,19 +432,24 @@ export function getFunnelReport() { // ── GET /reports/activities ────────────────────────────────── export function getActivityReport() { - const byType = dbQuery( - `SELECT type, CAST(COUNT(*) AS TEXT) as cnt FROM crm_activities GROUP BY type ORDER BY cnt DESC` - ); + const typeResult = dbGroupBy("crm_activities", { + group_by: "type", + count: true, + order_by: "cnt DESC" + }); const typeBreakdown = {}; - for (const row of (byType || [])) { + for (const row of (typeResult.data || [])) { typeBreakdown[row.type] = parseInt(row.cnt, 10); } - const byOwner = dbQuery( - `SELECT owner_id, CAST(COUNT(*) AS TEXT) as cnt FROM crm_activities GROUP BY owner_id ORDER BY cnt DESC LIMIT 10` - ); + const ownerResult = dbGroupBy("crm_activities", { + group_by: "owner_id", + count: true, + order_by: "cnt DESC", + limit: 10 + }); const ownerBreakdown = []; - for (const row of (byOwner || [])) { + for (const row of (ownerResult.data || [])) { ownerBreakdown.push({ owner_id: row.owner_id, count: parseInt(row.cnt, 10) }); } diff --git a/extensions/plugins/crm/sdk.d.ts b/extensions/plugins/crm/sdk.d.ts index d223409f..8bdf99e4 100644 --- a/extensions/plugins/crm/sdk.d.ts +++ b/extensions/plugins/crm/sdk.d.ts @@ -2,6 +2,17 @@ export interface DbExecResult { error?: string; rows_affected?: number; } +export interface DbCrudResult { + rows_affected?: number; +} +export interface DbFetchAllResult { + data: Record[]; + total: number; +} +export interface DbGroupByResult { + data: Record[]; + total: number; +} export interface PluginError { __plugin_error: boolean; __status: number; @@ -10,6 +21,15 @@ export interface PluginError { export declare const SDK_VERSION: string; export declare function dbQuery(sql: string, params?: unknown[]): Record[]; export declare function dbExec(sql: string, params?: unknown[]): DbExecResult; +export declare function dbInsert(table: string, data: Record | string, options?: Record): DbCrudResult; +export declare function dbFetchOne(table: string, where: Record | string | unknown[], options?: Record): Record | null; +export declare function dbFetchAll(table: string, where?: Record | string | unknown[] | null, options?: Record): DbFetchAllResult; +export declare function dbUpdate(table: string, data: Record | string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbDelete(table: string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbCount(table: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbIncrement(table: string, columns: Record | string, where?: Record | string | unknown[] | null, options?: Record): DbCrudResult; +export declare function dbSum(table: string, column: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbGroupBy(table: string, options: Record | string): DbGroupByResult; export declare function dbBegin(): { ok: boolean; }; diff --git a/extensions/plugins/ecommerce/jsconfig.json b/extensions/plugins/ecommerce/jsconfig.json deleted file mode 100644 index 379e6da9..00000000 --- a/extensions/plugins/ecommerce/jsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "module": "es2024", - "moduleResolution": "node", - "checkJs": true, - "noEmit": true, - "strict": false, - "paths": { - "sdk": ["./sdk.d.ts"] - } - }, - "include": ["*.js", "*.d.ts"] -} diff --git a/extensions/plugins/ecommerce/main.js b/extensions/plugins/ecommerce/main.js deleted file mode 100644 index 7e4974d6..00000000 --- a/extensions/plugins/ecommerce/main.js +++ /dev/null @@ -1,200 +0,0 @@ -import { dbQuery, dbExec, ok, fail, extractJson, newId, dbBegin, dbCommit, dbRollback } from 'sdk'; - -const nowISO = () => new Date().toISOString(); - -// ── GET /products ──────────────────────────────────────────── - -export function listProducts() { - const rows = dbQuery("SELECT id, name, slug, price, compare_at_price, stock, sku, images, featured FROM products WHERE status = 'published' ORDER BY created_at DESC LIMIT 50"); - if (!rows) return fail(500, "query failed"); - return ok({ items: rows, total: rows.length }); -} - -// ── GET /products/:id ──────────────────────────────────────── - -export function getProduct(input) { - const id = extractJson(input, "params.id"); - const rows = dbQuery("SELECT id, name, slug, description, price, compare_at_price, stock, sku, images, featured, weight FROM products WHERE id = ? AND status = 'published'", [id]); - if (!rows) return fail(500, "query failed"); - if (rows.length === 0) return fail(404, "product not found"); - return ok(rows[0]); -} - -// ── GET /cart ──────────────────────────────────────────────── - -export function viewCart(input) { - const data = extractJson(input, "body"); - const userId = data.user_id; - if (!userId) return fail(400, "user_id required"); - const rows = dbQuery("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.sku FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]); - if (!rows) return fail(500, "query failed"); - let total = 0; - for (const row of rows) { - row.subtotal = (row.price || 0) * (row.quantity || 0); - total += row.subtotal; - } - return ok({ items: rows, total }); -} - -// ── POST /cart ─────────────────────────────────────────────── - -export function addToCart(input) { - const data = extractJson(input, "body"); - const userId = data.user_id; - const productId = data.product_id; - const quantity = data.quantity || 1; - if (!userId || !productId) return fail(400, "user_id and product_id required"); - - const products = dbQuery("SELECT id, name, price, stock, status FROM products WHERE id = ?", [productId]); - if (!products) return fail(500, "query failed"); - if (products.length === 0) return fail(404, "product not found"); - if (products[0].status !== "published") return fail(400, "product not available"); - if (products[0].stock < quantity) return fail(400, "insufficient stock"); - - const existing = dbQuery("SELECT id, quantity FROM cart_items WHERE user_id = ? AND product_id = ?", [userId, productId]); - if (!existing) return fail(500, "query failed"); - - if (existing.length > 0) { - const newQty = existing[0].quantity + quantity; - const r = dbExec("UPDATE cart_items SET quantity = ? WHERE id = ?", [newQty, existing[0].id]); - if (r.error) return fail(500, r.error); - } else { - const id = newId(); - const now = nowISO(); - const r = dbExec("INSERT INTO cart_items (id, tenant_id, user_id, product_id, quantity, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?)", [id, userId, productId, quantity, now, now]); - if (r.error) return fail(500, r.error); - } - return ok({ added: true }); -} - -// ── DELETE /cart ───────────────────────────────────────────── - -export function clearCart(input) { - const data = extractJson(input, "body"); - if (!data.user_id) return fail(400, "user_id required"); - const r = dbExec("DELETE FROM cart_items WHERE user_id = ?", [data.user_id]); - if (r.error) return fail(500, r.error); - return ok({ cleared: true, rows_affected: r.rows_affected }); -} - -// ── PUT /cart/:id ──────────────────────────────────────────── - -export function updateCartItem(input) { - const itemId = extractJson(input, "params.id"); - const data = extractJson(input, "body"); - if (!itemId) return fail(400, "item id required"); - if (!data.quantity || data.quantity < 1) return fail(400, "quantity must be >= 1"); - const r = dbExec("UPDATE cart_items SET quantity = ? WHERE id = ?", [data.quantity, itemId]); - if (r.error) return fail(500, r.error); - if (r.rows_affected === 0) return fail(404, "cart item not found"); - return ok({ updated: true }); -} - -// ── DELETE /cart/:id ───────────────────────────────────────── - -export function removeCartItem(input) { - const itemId = extractJson(input, "params.id"); - if (!itemId) return fail(400, "item id required"); - const r = dbExec("DELETE FROM cart_items WHERE id = ?", [itemId]); - if (r.error) return fail(500, r.error); - if (r.rows_affected === 0) return fail(404, "cart item not found"); - return ok({ removed: true }); -} - -// ── POST /checkout (事务) ──────────────────────────────────── - -export function checkout(input) { - const data = extractJson(input, "body"); - const userId = data.user_id; - if (!userId) return fail(400, "user_id required"); - - const cartItems = dbQuery("SELECT c.id as cart_id, c.product_id, c.quantity, p.name, p.price, p.stock, p.status FROM cart_items c LEFT JOIN products p ON c.product_id = p.id WHERE c.user_id = ?", [userId]); - if (!cartItems) return fail(500, "query failed"); - if (cartItems.length === 0) return fail(400, "cart is empty"); - - for (const item of cartItems) { - if (!item.name) return fail(400, `product ${item.product_id} not found`); - if (item.status !== "published") return fail(400, `product ${item.name} not available`); - if (item.stock < item.quantity) return fail(400, `insufficient stock for ${item.name}`); - } - - let totalAmount = 0; - const orderItems = []; - for (const item of cartItems) { - const subtotal = item.price * item.quantity; - totalAmount += subtotal; - orderItems.push({ product_id: item.product_id, product_name: item.name, price: item.price, quantity: item.quantity, subtotal }); - } - - dbBegin(); - - const orderId = newId(); - const orderNo = `ORD-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substring(2, 6).toUpperCase()}`; - const shippingJson = data.shipping_address ? JSON.stringify(data.shipping_address) : ""; - - const r = dbExec( - "INSERT INTO orders (id, tenant_id, order_no, user_id, status, total_amount, shipping_address, note, created_at, updated_at) VALUES (?, 'default', ?, ?, 'pending', ?, ?, ?, ?, ?)", - [orderId, orderNo, userId, totalAmount, shippingJson, data.note || "", nowISO(), nowISO()] - ); - if (r.error) { - dbRollback(); - return fail(500, `create order failed: ${r.error}`); - } - - for (const oi of orderItems) { - const oiId = newId(); - const r2 = dbExec( - "INSERT INTO order_items (id, order_id, product_id, product_name, price, quantity, subtotal) VALUES (?, ?, ?, ?, ?, ?, ?)", - [oiId, orderId, oi.product_id, oi.product_name, oi.price, oi.quantity, oi.subtotal] - ); - if (r2.error) { - dbRollback(); - return fail(500, `create order item failed: ${r2.error}`); - } - - const r3 = dbExec("UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?", [oi.quantity, oi.product_id, oi.quantity]); - if (r3.error || r3.rows_affected === 0) { - dbRollback(); - return fail(500, `stock deduction failed for ${oi.product_name}`); - } - } - - dbExec("DELETE FROM cart_items WHERE user_id = ?", [userId]); - - dbCommit(); - - return ok({ - order_id: orderId, - order_no: orderNo, - status: "pending", - total_amount: totalAmount, - items_count: orderItems.length, - }); -} - -// ── GET /orders ────────────────────────────────────────────── - -export function listOrders(input) { - const data = extractJson(input, "body"); - if (!data.user_id) return fail(400, "user_id required"); - const rows = dbQuery("SELECT id, order_no, status, total_amount, note, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", [data.user_id]); - if (!rows) return fail(500, "query failed"); - return ok({ items: rows, total: rows.length }); -} - -// ── GET /orders/:id ────────────────────────────────────────── - -export function getOrder(input) { - const orderId = extractJson(input, "params.id"); - const data = extractJson(input, "body"); - if (!orderId) return fail(400, "order id required"); - const orders = dbQuery("SELECT id, order_no, user_id, status, total_amount, shipping_address, note, paid_at, shipped_at, created_at FROM orders WHERE id = ?", [orderId]); - if (!orders) return fail(500, "query failed"); - if (orders.length === 0) return fail(404, "order not found"); - const order = orders[0]; - if (data.user_id && order.user_id !== data.user_id) return fail(403, "forbidden"); - - const items = dbQuery("SELECT id, product_id, product_name, price, quantity, subtotal FROM order_items WHERE order_id = ?", [orderId]); - order.items = items || []; - return ok(order); -} diff --git a/extensions/plugins/ecommerce/manifest.toml b/extensions/plugins/ecommerce/manifest.toml deleted file mode 100644 index 11cb3de6..00000000 --- a/extensions/plugins/ecommerce/manifest.toml +++ /dev/null @@ -1,79 +0,0 @@ -[plugin] -id = "com.raisfast.ecommerce" -name = "E-Commerce API" -version = "0.1.0" -description = "电商 API:商品浏览、购物车、下单结算" -author = "raisfast" -license = "MIT" -runtime = "js" -language = "js" -entry = "main.js" - -[permissions] -max_memory_mb = 16 -timeout_ms = 5000 -database = [ - "products", - "read:product_categories", - "orders", - "order_items", - "cart_items", -] - -[[routes]] -method = "GET" -path = "/api/v1/plugins/ecommerce/products" -handler = "listProducts" - -[[routes]] -method = "GET" -path = "/api/v1/plugins/ecommerce/products/:id" -handler = "getProduct" - -[[routes]] -method = "GET" -path = "/api/v1/plugins/ecommerce/cart" -handler = "viewCart" -auth = "member" - -[[routes]] -method = "POST" -path = "/api/v1/plugins/ecommerce/cart" -handler = "addToCart" -auth = "member" - -[[routes]] -method = "DELETE" -path = "/api/v1/plugins/ecommerce/cart" -handler = "clearCart" -auth = "member" - -[[routes]] -method = "PUT" -path = "/api/v1/plugins/ecommerce/cart/:id" -handler = "updateCartItem" -auth = "member" - -[[routes]] -method = "DELETE" -path = "/api/v1/plugins/ecommerce/cart/:id" -handler = "removeCartItem" -auth = "member" - -[[routes]] -method = "POST" -path = "/api/v1/plugins/ecommerce/checkout" -handler = "checkout" -auth = "member" - -[[routes]] -method = "GET" -path = "/api/v1/plugins/ecommerce/orders" -handler = "listOrders" -auth = "member" - -[[routes]] -method = "GET" -path = "/api/v1/plugins/ecommerce/orders/:id" -handler = "getOrder" -auth = "member" diff --git a/extensions/plugins/ecommerce/sdk.d.ts b/extensions/plugins/ecommerce/sdk.d.ts deleted file mode 100644 index d223409f..00000000 --- a/extensions/plugins/ecommerce/sdk.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface DbExecResult { - error?: string; - rows_affected?: number; -} -export interface PluginError { - __plugin_error: boolean; - __status: number; - __message: string; -} -export declare const SDK_VERSION: string; -export declare function dbQuery(sql: string, params?: unknown[]): Record[]; -export declare function dbExec(sql: string, params?: unknown[]): DbExecResult; -export declare function dbBegin(): { - ok: boolean; -}; -export declare function dbCommit(): { - ok: boolean; -}; -export declare function dbRollback(): { - ok: boolean; -}; -export declare function httpGet(url: string): string | null; -export declare function httpGetJson(url: string): Record | null; -export declare function httpPost(url: string, body: Record | string): string | null; -export declare function httpPostJson(url: string, body: Record | string): Record | null; -export declare function configGet(key: string): string | null; -export declare function storeGet(key: string): string | null; -export declare function storeSet(key: string, value: string): boolean; -export declare function vfsRead(path: string): string | null; -export declare function vfsWrite(path: string, content: string): boolean; -export declare function vfsDelete(path: string): boolean; -export declare function vfsExists(path: string): boolean; -export declare function vfsList(path: string): string[] | null; -export declare function vfsStat(path: string): Record | null; -export declare function getPost(slug: string): Record | null; -export declare function ok(data: unknown): any; -export declare function fail(status: number, msg: string): PluginError; -export declare function extractJson(input: any, field?: string): any; -export declare function logInfo(msg: string): void; -export declare function logWarn(msg: string): void; -export declare function logError(msg: string): void; -export declare function newId(): string; -export declare function eventEmit(type: string, data: string | Record): void; diff --git a/extensions/plugins/first-ext/init.lua b/extensions/plugins/first-ext/init.lua index 87a89609..8b37a000 100644 --- a/extensions/plugins/first-ext/init.lua +++ b/extensions/plugins/first-ext/init.lua @@ -8,17 +8,10 @@ local sdk = require("sdk") Plugin = {} Plugin.stats_overview = function(input) - local posts = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as total FROM posts") - if not posts then return sdk.fail(500, "query failed") end - - local comments = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as total FROM comments") - local users = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as total FROM users") - local published = sdk.dbQuery("SELECT CAST(COUNT(*) AS TEXT) as total FROM posts WHERE status = 'published'") - - local total_posts = posts[1] and tonumber(posts[1].total) or 0 - local total_comments = comments and comments[1] and tonumber(comments[1].total) or 0 - local total_users = users and users[1] and tonumber(users[1].total) or 0 - local total_published = published and published[1] and tonumber(published[1].total) or 0 + local total_posts = sdk.dbCount("posts") + local total_comments = sdk.dbCount("comments") + local total_users = sdk.dbCount("users") + local total_published = sdk.dbCount("posts", { status = "published" }) sdk.logInfo("[blog-stats] GET overview") @@ -31,27 +24,19 @@ Plugin.stats_overview = function(input) end Plugin.stats_posts = function(input) - local result = sdk.dbQuery("SELECT status, CAST(COUNT(*) AS TEXT) as count FROM posts GROUP BY status") - if not result then return sdk.fail(500, "query failed") end - return sdk.ok(result) + local result = sdk.dbGroupBy("posts", { + group_by = "status", + count = true + }) + return sdk.ok(result.data or {}) end Plugin.stats_recent = function(input) - local result = sdk.dbQuery( - "SELECT title, slug, view_count, published_at " - .. "FROM posts WHERE status = 'published' " - .. "ORDER BY published_at DESC LIMIT 10" - ) - if not result then return sdk.fail(500, "query failed") end - return sdk.ok(result) + local result = sdk.dbFetchAll("posts", { status = "published" }, { order_by = "published_at DESC", limit = 10 }) + return sdk.ok(result.data or {}) end Plugin.stats_top = function(input) - local result = sdk.dbQuery( - "SELECT title, slug, view_count " - .. "FROM posts WHERE status = 'published' " - .. "ORDER BY view_count DESC LIMIT 10" - ) - if not result then return sdk.fail(500, "query failed") end - return sdk.ok(result) + local result = sdk.dbFetchAll("posts", { status = "published" }, { order_by = "view_count DESC", limit = 10 }) + return sdk.ok(result.data or {}) end diff --git a/extensions/plugins/first-ext/sdk.d.ts b/extensions/plugins/first-ext/sdk.d.ts index d223409f..8bdf99e4 100644 --- a/extensions/plugins/first-ext/sdk.d.ts +++ b/extensions/plugins/first-ext/sdk.d.ts @@ -2,6 +2,17 @@ export interface DbExecResult { error?: string; rows_affected?: number; } +export interface DbCrudResult { + rows_affected?: number; +} +export interface DbFetchAllResult { + data: Record[]; + total: number; +} +export interface DbGroupByResult { + data: Record[]; + total: number; +} export interface PluginError { __plugin_error: boolean; __status: number; @@ -10,6 +21,15 @@ export interface PluginError { export declare const SDK_VERSION: string; export declare function dbQuery(sql: string, params?: unknown[]): Record[]; export declare function dbExec(sql: string, params?: unknown[]): DbExecResult; +export declare function dbInsert(table: string, data: Record | string, options?: Record): DbCrudResult; +export declare function dbFetchOne(table: string, where: Record | string | unknown[], options?: Record): Record | null; +export declare function dbFetchAll(table: string, where?: Record | string | unknown[] | null, options?: Record): DbFetchAllResult; +export declare function dbUpdate(table: string, data: Record | string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbDelete(table: string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbCount(table: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbIncrement(table: string, columns: Record | string, where?: Record | string | unknown[] | null, options?: Record): DbCrudResult; +export declare function dbSum(table: string, column: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbGroupBy(table: string, options: Record | string): DbGroupByResult; export declare function dbBegin(): { ok: boolean; }; diff --git a/extensions/plugins/forum/main.js b/extensions/plugins/forum/main.js index d63c795a..c7e0a3c7 100644 --- a/extensions/plugins/forum/main.js +++ b/extensions/plugins/forum/main.js @@ -1,4 +1,6 @@ -import { dbQuery, dbExec, ok, fail, extractJson, logInfo, logWarn, logError, newId, eventEmit } from 'sdk'; +import { dbQuery, dbExec, ok, fail, extractJson, logInfo, logWarn, logError, newId, eventEmit, + dbInsert, dbFetchOne, dbFetchAll, dbUpdate, dbDelete, dbCount, + dbIncrement, dbSum } from 'sdk'; const nowISO = () => new Date().toISOString(); @@ -12,8 +14,8 @@ export function on_content_creating(input) { if (ct === "forum_reply") { const topicId = body.topic_id; if (topicId) { - const topics = dbQuery("SELECT is_locked FROM forum_topics WHERE id = ?", [topicId]); - if (topics?.length > 0 && topics[0].is_locked) { + const topic = dbFetchOne("forum_topics", { id: topicId }); + if (topic && topic.is_locked) { return fail(400, "topic is locked"); } } @@ -28,26 +30,31 @@ export function on_content_created(input) { const id = data.id; if (ct === "forum_topic") { - const topics = dbQuery("SELECT board_id FROM forum_topics WHERE id = ?", [id]); - if (topics?.length > 0) { + const topic = dbFetchOne("forum_topics", { id }); + if (topic) { const now = nowISO(); - dbExec("UPDATE forum_boards SET topic_count = topic_count + 1, post_count = post_count + 1, last_activity_at = ?, last_topic_id = ? WHERE id = ?", - [now, id, topics[0].board_id]); + dbIncrement("forum_boards", + { topic_count: 1, post_count: 1 }, + { id: topic.board_id }, + { set: { last_activity_at: now, last_topic_id: id } }); } } if (ct === "forum_reply") { - const replies = dbQuery("SELECT topic_id, author_id FROM forum_replies WHERE id = ?", [id]); - if (replies?.length > 0) { - const reply = replies[0]; + const reply = dbFetchOne("forum_replies", { id }); + if (reply) { const now = nowISO(); - dbExec("UPDATE forum_topics SET reply_count = reply_count + 1, last_reply_at = ?, last_reply_user_id = ?, updated_at = ? WHERE id = ?", - [now, reply.author_id, now, reply.topic_id]); + dbIncrement("forum_topics", + { reply_count: 1 }, + { id: reply.topic_id }, + { set: { last_reply_at: now, last_reply_user_id: reply.author_id, updated_at: now } }); - const topics = dbQuery("SELECT board_id FROM forum_topics WHERE id = ?", [reply.topic_id]); - if (topics?.length > 0) { - dbExec("UPDATE forum_boards SET post_count = post_count + 1, last_activity_at = ? WHERE id = ?", - [now, topics[0].board_id]); + const topic = dbFetchOne("forum_topics", { id: reply.topic_id }); + if (topic) { + dbIncrement("forum_boards", + { post_count: 1 }, + { id: topic.board_id }, + { set: { last_activity_at: now } }); } } } @@ -61,18 +68,22 @@ export function on_content_deleted(input) { const id = data.id; if (ct === "forum_topic") { - const topics = dbQuery("SELECT board_id FROM forum_topics WHERE id = ?", [id]); - if (topics?.length > 0) { - dbExec("UPDATE forum_boards SET topic_count = CASE WHEN topic_count > 0 THEN topic_count - 1 ELSE 0 END, post_count = CASE WHEN post_count > 0 THEN post_count - 1 ELSE 0 END WHERE id = ?", - [topics[0].board_id]); + const topic = dbFetchOne("forum_topics", { id }); + if (topic) { + dbIncrement("forum_boards", + { topic_count: -1, post_count: -1 }, + { id: topic.board_id }, + { min: 0 }); } } if (ct === "forum_reply") { - const replies = dbQuery("SELECT topic_id FROM forum_replies WHERE id = ?", [id]); - if (replies?.length > 0) { - dbExec("UPDATE forum_topics SET reply_count = CASE WHEN reply_count > 0 THEN reply_count - 1 ELSE 0 END WHERE id = ?", - [replies[0].topic_id]); + const reply = dbFetchOne("forum_replies", { id }); + if (reply) { + dbIncrement("forum_topics", + { reply_count: -1 }, + { id: reply.topic_id }, + { min: 0 }); } } @@ -85,7 +96,7 @@ export function on_content_viewed(input) { const id = data.id; if (ct === "forum_topic") { - dbExec("UPDATE forum_topics SET view_count = view_count + 1 WHERE id = ?", [id]); + dbIncrement("forum_topics", { view_count: 1 }, { id }); } return ok(data); @@ -101,23 +112,16 @@ export function listBoardTopics(input) { if (pageSize < 1 || pageSize > 100) pageSize = 20; const offset = (page - 1) * pageSize; - const boards = dbQuery("SELECT id FROM forum_boards WHERE slug = ?", [slug]); - if (!boards || boards.length === 0) return fail(404, `board not found for slug: ${slug}`); - const boardId = boards[0].id; + const board = dbFetchOne("forum_boards", { slug }); + if (!board) return fail(404, `board not found for slug: ${slug}`); + const boardId = board.id; - const totalResult = dbQuery("SELECT CAST(COUNT(*) AS TEXT) as cnt FROM forum_topics WHERE board_id = ?", [boardId]); - const total = totalResult?.[0] ? parseInt(totalResult[0].cnt, 10) : 0; + const total = dbCount("forum_topics", { board_id: boardId }); - const rows = dbQuery( - "SELECT id, title, slug, author_id, reply_count, view_count, is_pinned, is_locked, is_solved, " + - "last_reply_at, last_reply_user_id, tags, created_at " + - "FROM forum_topics WHERE board_id = ? " + - "ORDER BY is_pinned DESC, last_reply_at DESC, created_at DESC " + - "LIMIT ? OFFSET ?", - [boardId, pageSize, offset] - ); + const result = dbFetchAll("forum_topics", { board_id: boardId }, + { order_by: "is_pinned DESC, last_reply_at DESC, created_at DESC", limit: pageSize, offset }); - return ok({ items: rows || [], total, page, page_size: pageSize, board_id: boardId }); + return ok({ items: result.data || [], total, page, page_size: pageSize, board_id: boardId }); } // ── PUT /replies/:id/accept ───────────────────────────────── @@ -128,18 +132,18 @@ export function acceptAnswer(input) { const userId = data.user_id; if (!userId) return fail(400, "user_id required"); - const replies = dbQuery("SELECT id, topic_id, author_id FROM forum_replies WHERE id = ?", [replyId]); - if (!replies || replies.length === 0) return fail(404, "reply not found"); + const reply = dbFetchOne("forum_replies", { id: replyId }); + if (!reply) return fail(404, "reply not found"); - const reply = replies[0]; - const topics = dbQuery("SELECT id, author_id FROM forum_topics WHERE id = ?", [reply.topic_id]); - if (!topics || topics.length === 0) return fail(404, "topic not found"); + const topic = dbFetchOne("forum_topics", { id: reply.topic_id }); + if (!topic) return fail(404, "topic not found"); - if (topics[0].author_id !== userId) return fail(403, "only topic author can accept answer"); + if (topic.author_id !== userId) return fail(403, "only topic author can accept answer"); - dbExec("UPDATE forum_replies SET is_answer = 0 WHERE topic_id = ? AND is_answer = 1", [reply.topic_id]); - dbExec("UPDATE forum_replies SET is_answer = 1, updated_at = ? WHERE id = ?", [nowISO(), replyId]); - dbExec("UPDATE forum_topics SET is_solved = 1, updated_at = ? WHERE id = ?", [nowISO(), reply.topic_id]); + const now = nowISO(); + dbUpdate("forum_replies", { is_answer: 0 }, { topic_id: reply.topic_id, is_answer: 1 }); + dbUpdate("forum_replies", { is_answer: 1, updated_at: now }, { id: replyId }); + dbUpdate("forum_topics", { is_solved: 1, updated_at: now }, { id: reply.topic_id }); return ok({ id: replyId, is_answer: true, topic_id: reply.topic_id }); } @@ -160,18 +164,17 @@ export function vote(input) { if (!targetId) return fail(400, "target_id required"); if (targetType !== "topic" && targetType !== "reply") return fail(400, "target_type must be topic or reply"); - const existing = dbQuery("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]); - if (existing?.length > 0) { - const oldValue = parseInt(existing[0].value, 10); + const existing = dbFetchOne("forum_votes", { target_type: targetType, target_id: targetId, user_id: userId }); + if (existing) { + const oldValue = parseInt(existing.value, 10); const diff = value - oldValue; if (diff === 0) return fail(400, "already voted"); - dbExec("UPDATE forum_votes SET value = ?, updated_at = ? WHERE id = ?", [value, nowISO(), existing[0].id]); + dbUpdate("forum_votes", { value, updated_at: nowISO() }, { id: existing.id }); updateVoteCount(targetType, targetId, diff); } else { const id = newId(); const now = nowISO(); - dbExec("INSERT INTO forum_votes (id, tenant_id, target_type, target_id, user_id, value, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?, ?)", - [id, targetType, targetId, userId, value, now, now]); + dbInsert("forum_votes", { id, tenant_id: "default", target_type: targetType, target_id: targetId, user_id: userId, value, created_at: now, updated_at: now }); updateVoteCount(targetType, targetId, value); } @@ -190,11 +193,11 @@ export function unvote(input) { if (!targetType) return fail(400, "target_type required"); if (!targetId) return fail(400, "target_id required"); - const existing = dbQuery("SELECT id, value FROM forum_votes WHERE target_type = ? AND target_id = ? AND user_id = ?", [targetType, targetId, userId]); - if (!existing || existing.length === 0) return fail(404, "vote not found"); + const existing = dbFetchOne("forum_votes", { target_type: targetType, target_id: targetId, user_id: userId }); + if (!existing) return fail(404, "vote not found"); - const oldValue = parseInt(existing[0].value, 10); - dbExec("DELETE FROM forum_votes WHERE id = ?", [existing[0].id]); + const oldValue = parseInt(existing.value, 10); + dbDelete("forum_votes", { id: existing.id }); updateVoteCount(targetType, targetId, -oldValue); return ok({ removed: true }); @@ -202,7 +205,7 @@ export function unvote(input) { const updateVoteCount = (targetType, targetId, diff) => { const table = targetType === "topic" ? "forum_topics" : "forum_replies"; - dbExec(`UPDATE ${table} SET vote_count = vote_count + ? WHERE id = ?`, [diff, targetId]); + dbIncrement(table, { vote_count: diff }, { id: targetId }); }; // ── Polls ─────────────────────────────────────────────────── @@ -223,25 +226,23 @@ export function createPoll(input) { if (maxChoices < 1) maxChoices = 1; if (maxChoices > options.length) maxChoices = options.length; - const topics = dbQuery("SELECT id, author_id FROM forum_topics WHERE id = ?", [topicId]); - if (!topics || topics.length === 0) return fail(404, "topic not found"); - if (topics[0].author_id !== userId) return fail(403, "only topic author can create poll"); + const topic = dbFetchOne("forum_topics", { id: topicId }); + if (!topic) return fail(404, "topic not found"); + if (topic.author_id !== userId) return fail(403, "only topic author can create poll"); - const existing = dbQuery("SELECT id FROM forum_polls WHERE topic_id = ?", [topicId]); - if (existing?.length > 0) return fail(400, "poll already exists for this topic"); + const existing = dbFetchOne("forum_polls", { topic_id: topicId }); + if (existing) return fail(400, "poll already exists for this topic"); const pollId = newId(); const now = nowISO(); - dbExec("INSERT INTO forum_polls (id, tenant_id, topic_id, question, max_choices, is_closed, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, 0, ?, ?)", - [pollId, topicId, question, maxChoices, now, now]); + dbInsert("forum_polls", { id: pollId, tenant_id: "default", topic_id: topicId, question, max_choices: maxChoices, is_closed: 0, created_at: now, updated_at: now }); const createdOptions = []; for (let i = 0; i < options.length; i++) { const optText = (options[i] || "").trim(); if (!optText) continue; const optId = newId(); - dbExec("INSERT INTO forum_poll_options (id, tenant_id, poll_id, text, vote_count, sort_order) VALUES (?, 'default', ?, ?, 0, ?)", - [optId, pollId, optText, i]); + dbInsert("forum_poll_options", { id: optId, tenant_id: "default", poll_id: pollId, text: optText, vote_count: 0, sort_order: i }); createdOptions.push({ id: optId, text: optText, vote_count: 0, sort_order: i }); } @@ -275,35 +276,32 @@ export function getPoll(input) { } } - const polls = dbQuery("SELECT id, topic_id, question, max_choices, is_closed, created_at FROM forum_polls WHERE topic_id = ?", [topicId]); - if (!polls || polls.length === 0) return ok(null); + const poll = dbFetchOne("forum_polls", { topic_id: topicId }); + if (!poll) return ok(null); - const poll = polls[0]; - const options = dbQuery("SELECT id, text, CAST(vote_count AS TEXT) as vote_count, CAST(sort_order AS TEXT) as sort_order FROM forum_poll_options WHERE poll_id = ? ORDER BY sort_order", [poll.id]); + const optsResult = dbFetchAll("forum_poll_options", { poll_id: poll.id }, { order_by: "sort_order ASC" }); + const options = optsResult.data || []; - const totalResult = dbQuery("SELECT CAST(SUM(vote_count) AS TEXT) as total FROM forum_poll_options WHERE poll_id = ?", [poll.id]); - const totalVotes = (totalResult?.[0]?.total) ? parseInt(totalResult[0].total, 10) : 0; + const totalVotes = dbSum("forum_poll_options", "vote_count", { poll_id: poll.id }); const userVotes = []; if (userId) { - const votes = dbQuery("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [poll.id, userId]); - if (votes) { - for (const vote of votes) { - userVotes.push(vote.option_id); + const votesResult = dbFetchAll("forum_poll_votes", { poll_id: poll.id, user_id: userId }); + if (votesResult.data) { + for (const v of votesResult.data) { + userVotes.push(v.option_id); } } } const fixedOptions = []; - if (options) { - for (const opt of options) { - fixedOptions.push({ - id: opt.id, - text: opt.text, - vote_count: parseInt(opt.vote_count, 10) || 0, - sort_order: parseInt(opt.sort_order, 10) || 0 - }); - } + for (const opt of options) { + fixedOptions.push({ + id: opt.id, + text: opt.text, + vote_count: parseInt(opt.vote_count, 10) || 0, + sort_order: parseInt(opt.sort_order, 10) || 0 + }); } return ok({ @@ -328,27 +326,25 @@ export function castVote(input) { if (!userId) return fail(400, "user_id required"); if (!optionIds || optionIds.length === 0) return fail(400, "option_ids required"); - const polls = dbQuery("SELECT id, topic_id, question, max_choices, is_closed FROM forum_polls WHERE id = ?", [pollId]); - if (!polls || polls.length === 0) return fail(404, "poll not found"); + const poll = dbFetchOne("forum_polls", { id: pollId }); + if (!poll) return fail(404, "poll not found"); - const poll = polls[0]; if (parseInt(poll.is_closed, 10) === 1) return fail(400, "poll is closed"); const maxChoices = parseInt(poll.max_choices, 10); if (optionIds.length > maxChoices) return fail(400, `too many choices (max ${maxChoices})`); - const existingVotes = dbQuery("SELECT option_id FROM forum_poll_votes WHERE poll_id = ? AND user_id = ?", [pollId, userId]); - if (existingVotes?.length > 0) return fail(400, "already voted"); + const existingVotes = dbFetchAll("forum_poll_votes", { poll_id: pollId, user_id: userId }); + if (existingVotes.data && existingVotes.data.length > 0) return fail(400, "already voted"); for (const optId of optionIds) { - const opts = dbQuery("SELECT id FROM forum_poll_options WHERE id = ? AND poll_id = ?", [optId, pollId]); - if (!opts || opts.length === 0) return fail(400, `option not found: ${optId}`); + const opt = dbFetchOne("forum_poll_options", { id: optId, poll_id: pollId }); + if (!opt) return fail(400, `option not found: ${optId}`); const voteId = newId(); const now = nowISO(); - dbExec("INSERT INTO forum_poll_votes (id, tenant_id, poll_id, option_id, user_id, created_at, updated_at) VALUES (?, 'default', ?, ?, ?, ?, ?)", - [voteId, pollId, optId, userId, now, now]); - dbExec("UPDATE forum_poll_options SET vote_count = vote_count + 1 WHERE id = ?", [optId]); + dbInsert("forum_poll_votes", { id: voteId, tenant_id: "default", poll_id: pollId, option_id: optId, user_id: userId, created_at: now, updated_at: now }); + dbIncrement("forum_poll_options", { vote_count: 1 }, { id: optId }); } return ok({ poll_id: pollId, voted_options: optionIds }); @@ -361,15 +357,15 @@ export function deletePoll(input) { if (!userId) return fail(400, "user_id required"); - const polls = dbQuery("SELECT id, topic_id FROM forum_polls WHERE id = ?", [pollId]); - if (!polls || polls.length === 0) return fail(404, "poll not found"); + const poll = dbFetchOne("forum_polls", { id: pollId }); + if (!poll) return fail(404, "poll not found"); - const topics = dbQuery("SELECT author_id FROM forum_topics WHERE id = ?", [polls[0].topic_id]); - if (!topics || topics.length === 0 || topics[0].author_id !== userId) return fail(403, "only topic author can delete poll"); + const topic = dbFetchOne("forum_topics", { id: poll.topic_id }); + if (!topic || topic.author_id !== userId) return fail(403, "only topic author can delete poll"); - dbExec("DELETE FROM forum_poll_votes WHERE poll_id = ?", [pollId]); - dbExec("DELETE FROM forum_poll_options WHERE poll_id = ?", [pollId]); - dbExec("DELETE FROM forum_polls WHERE id = ?", [pollId]); + dbDelete("forum_poll_votes", { poll_id: pollId }); + dbDelete("forum_poll_options", { poll_id: pollId }); + dbDelete("forum_polls", { id: pollId }); return ok({ deleted: true }); } diff --git a/extensions/plugins/forum/sdk.d.ts b/extensions/plugins/forum/sdk.d.ts index d223409f..8bdf99e4 100644 --- a/extensions/plugins/forum/sdk.d.ts +++ b/extensions/plugins/forum/sdk.d.ts @@ -2,6 +2,17 @@ export interface DbExecResult { error?: string; rows_affected?: number; } +export interface DbCrudResult { + rows_affected?: number; +} +export interface DbFetchAllResult { + data: Record[]; + total: number; +} +export interface DbGroupByResult { + data: Record[]; + total: number; +} export interface PluginError { __plugin_error: boolean; __status: number; @@ -10,6 +21,15 @@ export interface PluginError { export declare const SDK_VERSION: string; export declare function dbQuery(sql: string, params?: unknown[]): Record[]; export declare function dbExec(sql: string, params?: unknown[]): DbExecResult; +export declare function dbInsert(table: string, data: Record | string, options?: Record): DbCrudResult; +export declare function dbFetchOne(table: string, where: Record | string | unknown[], options?: Record): Record | null; +export declare function dbFetchAll(table: string, where?: Record | string | unknown[] | null, options?: Record): DbFetchAllResult; +export declare function dbUpdate(table: string, data: Record | string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbDelete(table: string, where: Record | string | unknown[], options?: Record): DbCrudResult; +export declare function dbCount(table: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbIncrement(table: string, columns: Record | string, where?: Record | string | unknown[] | null, options?: Record): DbCrudResult; +export declare function dbSum(table: string, column: string, where?: Record | string | unknown[] | null, options?: Record): number; +export declare function dbGroupBy(table: string, options: Record | string): DbGroupByResult; export declare function dbBegin(): { ok: boolean; }; diff --git a/plugin-sdk/js/js_plugin_v1.js b/plugin-sdk/js/js_plugin_v1.js index e4e0d825..a71ac656 100644 --- a/plugin-sdk/js/js_plugin_v1.js +++ b/plugin-sdk/js/js_plugin_v1.js @@ -17,6 +17,70 @@ export function dbExec(sql, params) { const result = RaisFastHost.dbExecute(sql, JSON.stringify(params ?? [])); return JSON.parse(result); } +export function dbInsert(table, data, options) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbInsert(table, dataStr, optStr)); + if (result.error) throw new Error(result.error); + return result; +} +export function dbFetchOne(table, where, options) { + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbFetchOne(table, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result.data; +} +export function dbFetchAll(table, where, options) { + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbFetchAll(table, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result; +} +export function dbUpdate(table, data, where, options) { + const dataStr = typeof data === "string" ? data : JSON.stringify(data); + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbUpdate(table, dataStr, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result; +} +export function dbDelete(table, where, options) { + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbDelete(table, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result; +} +export function dbCount(table, where, options) { + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbCount(table, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result.count; +} +export function dbIncrement(table, columns, where, options) { + const colStr = typeof columns === "string" ? columns : JSON.stringify(columns); + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbIncrement(table, colStr, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result; +} +export function dbSum(table, column, where, options) { + const whereStr = where == null ? "{}" : (typeof where === "string" ? where : JSON.stringify(where)); + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbSum(table, column, whereStr, optStr)); + if (result.error) throw new Error(result.error); + return result.sum; +} +export function dbGroupBy(table, options) { + const optStr = options == null ? "{}" : (typeof options === "string" ? options : JSON.stringify(options)); + const result = JSON.parse(RaisFastHost.dbGroupBy(table, optStr)); + if (result.error) throw new Error(result.error); + return result; +} export function dbBegin() { const result = JSON.parse(RaisFastHost.dbBegin()); if (!result.ok) diff --git a/plugin-sdk/lua/lua_plugin_v1.lua b/plugin-sdk/lua/lua_plugin_v1.lua index b96a7b40..5f8b4e62 100644 --- a/plugin-sdk/lua/lua_plugin_v1.lua +++ b/plugin-sdk/lua/lua_plugin_v1.lua @@ -19,6 +19,79 @@ function M.dbExec(sql, params) return RaisFastHost.jsonDecode(result) end +function M.dbInsert(table, data, options) + local dataStr = type(data) == "string" and data or RaisFastHost.jsonEncode(data) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbInsert(table, dataStr, optStr)) + if result.error then error(result.error) end + return result +end + +function M.dbFetchOne(table, where, options) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbFetchOne(table, whereStr, optStr)) + if result.error then error(result.error) end + return result.data +end + +function M.dbFetchAll(table, where, options) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbFetchAll(table, whereStr, optStr)) + if result.error then error(result.error) end + return result +end + +function M.dbUpdate(table, data, where, options) + local dataStr = type(data) == "string" and data or RaisFastHost.jsonEncode(data) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbUpdate(table, dataStr, whereStr, optStr)) + if result.error then error(result.error) end + return result +end + +function M.dbDelete(table, where, options) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbDelete(table, whereStr, optStr)) + if result.error then error(result.error) end + return result +end + +function M.dbCount(table, where, options) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbCount(table, whereStr, optStr)) + if result.error then error(result.error) end + return result.count +end + +function M.dbIncrement(table, columns, where, options) + local colStr = type(columns) == "string" and columns or RaisFastHost.jsonEncode(columns) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbIncrement(table, colStr, whereStr, optStr)) + if result.error then error(result.error) end + return result +end + +function M.dbSum(table, column, where, options) + local whereStr = (where == nil) and "{}" or (type(where) == "string" and where or RaisFastHost.jsonEncode(where)) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbSum(table, column, whereStr, optStr)) + if result.error then error(result.error) end + return result.sum +end + +function M.dbGroupBy(table, options) + local optStr = (options == nil) and "{}" or (type(options) == "string" and options or RaisFastHost.jsonEncode(options)) + local result = RaisFastHost.jsonDecode(RaisFastHost.dbGroupBy(table, optStr)) + if result.error then error(result.error) end + return result +end + function M.dbBegin() local result = RaisFastHost.jsonDecode(RaisFastHost.dbBegin()) if not result.ok then error("dbBegin failed") end diff --git a/src/models/cart_item.rs b/src/models/cart_item.rs index 97181595..0c7b9669 100644 --- a/src/models/cart_item.rs +++ b/src/models/cart_item.rs @@ -53,18 +53,16 @@ pub async fn find_by_user_and_product( ) .map_err(Into::into) } else { - let sql = if tenant_id.is_some() { - "SELECT * FROM cart_items WHERE user_id = ? AND product_id = ? AND variant_id IS NULL AND tenant_id = ?" - } else { - "SELECT * FROM cart_items WHERE user_id = ? AND product_id = ? AND variant_id IS NULL" - }; - let mut q = sqlx::query_as::<_, CartItem>(sql) - .bind(user_id) - .bind(product_id); - if let Some(tid) = tenant_id { - q = q.bind(tid); - } - Ok(q.fetch_optional(pool).await?) + raisfast_derive::crud_find!( + pool, + "cart_items", + CartItem, + "user_id" => user_id, + and: ["product_id" => product_id], + and_null: ["variant_id"], + tenant: tenant_id + ) + .map_err(Into::into) } } diff --git a/src/plugins/host_common.rs b/src/plugins/host_common.rs index 7f476836..38dd20be 100644 --- a/src/plugins/host_common.rs +++ b/src/plugins/host_common.rs @@ -23,6 +23,65 @@ struct TxState { conn: DbPoolConnection, } +struct WhereResult { + clause: String, + params: Vec, +} + +enum CrudTenant { + Auto, + Disabled, + Explicit(String), +} + +struct CrudOptions { + tenant: CrudTenant, + order_by: Option, + limit: Option, + offset: Option, +} + +impl CrudOptions { + fn parse(json: &str) -> Self { + let mut opts = Self { + tenant: CrudTenant::Auto, + order_by: None, + limit: None, + offset: None, + }; + let Ok(obj) = serde_json::from_str::>(json) + else { + return opts; + }; + match obj.get("tenant") { + Some(serde_json::Value::Bool(false)) => opts.tenant = CrudTenant::Disabled, + Some(serde_json::Value::String(s)) => opts.tenant = CrudTenant::Explicit(s.clone()), + _ => {} + } + if let Some(serde_json::Value::String(s)) = obj.get("order_by") { + opts.order_by = Some(s.clone()); + } + if let Some(serde_json::Value::Number(n)) = obj.get("limit") { + opts.limit = n.as_u64().map(|v| v as usize); + } + if let Some(serde_json::Value::Number(n)) = obj.get("offset") { + opts.offset = n.as_u64().map(|v| v as usize); + } + opts + } + + fn tenant_is_disabled(&self) -> bool { + matches!(self.tenant, CrudTenant::Disabled) + } + + fn tenant_value_owned(&self) -> Option { + match &self.tenant { + CrudTenant::Explicit(s) => Some(s.clone()), + _ => None, + } + } +} + /// Plugin host context /// /// Holds all shared state needed by a single plugin. Business logic methods are synchronous @@ -36,6 +95,7 @@ pub struct HostContext { tx: Mutex>, vfs: Option>, event_bus: Option, + content_type_registry: Option>, } impl Clone for HostContext { @@ -49,6 +109,7 @@ impl Clone for HostContext { tx: Mutex::new(None), vfs: self.vfs.clone(), event_bus: self.event_bus.clone(), + content_type_registry: self.content_type_registry.clone(), } } } @@ -72,6 +133,7 @@ impl HostContext { tx: Mutex::new(None), vfs: None, event_bus: None, + content_type_registry: None, } } @@ -80,6 +142,14 @@ impl HostContext { self.event_bus = Some(bus); } + /// Set the content type registry (called after PluginManager initialization) + pub fn set_content_type_registry( + &mut self, + reg: Arc, + ) { + self.content_type_registry = Some(reg); + } + /// Return the plugin ID #[must_use] pub fn plugin_id(&self) -> &str { @@ -459,6 +529,773 @@ impl HostContext { } } + // ── High-level CRUD API ───────────────────────────────────── + + /// Check if a table is a content type table with tenantable protocol + fn is_tenantable_table(&self, table: &str) -> bool { + self.content_type_registry + .as_ref() + .and_then(|reg| reg.get_by_table(table)) + .is_some_and(|ct| ct.implements_protocol("tenantable")) + } + + fn check_table_readable(&self, table: &str) -> Result<(), String> { + if PermissionChecker::is_protected_table(table, &self.config.builtins.protected_tables()) { + return Err(format!("table '{table}' is protected")); + } + if !PermissionChecker::is_table_readable(&self.permissions, table) { + return Err(format!("no read permission for table: {table}")); + } + Ok(()) + } + + fn check_table_writable(&self, table: &str) -> Result<(), String> { + if PermissionChecker::is_protected_table(table, &self.config.builtins.protected_tables()) { + return Err(format!("table '{table}' is protected")); + } + if !PermissionChecker::is_table_writable(&self.permissions, table) { + return Err(format!("no write permission for table: {table}")); + } + Ok(()) + } + + fn require_pool(&self) -> Result<&Pool, String> { + self.pool + .as_ref() + .ok_or_else(|| "no database access".to_string()) + } + + /// Insert a row into a table. + /// + /// `data_json` is a JSON object of column-value pairs. + /// `options_json` is optional: `{ "tenant": "tenant_id" }` or `{ "tenant": false }`. + /// + /// Returns `{"data":{...},"rows_affected":1}` or `{"error":"..."}`. + #[must_use] + pub fn db_insert(&self, table: &str, data_json: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_writable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let mut data: serde_json::Map = + match serde_json::from_str(data_json) { + Ok(d) => d, + Err(e) => return format!(r#"{{"error":"invalid data JSON: {e}"}}"#), + }; + + let opts = CrudOptions::parse(options_json); + if self.is_tenantable_table(table) { + match &opts.tenant { + CrudTenant::Auto => {} + CrudTenant::Explicit(tid) => { + data.insert("tenant_id".into(), serde_json::Value::String(tid.clone())); + } + CrudTenant::Disabled => {} + } + } + + let mut cols = Vec::new(); + let mut vals = Vec::new(); + let mut args = DbArguments::default(); + for (k, v) in &data { + cols.push(k.clone()); + Self::add_param(&mut args, v); + vals.push(crate::db::dialect::ph(vals.len() + 1)); + } + + let sql = format!( + "INSERT INTO {table} ({}) VALUES ({})", + cols.join(", "), + vals.join(", ") + ); + + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await }) { + Ok(r) => format!(r#"{{"rows_affected":{}}}"#, r.rows_affected()), + Err(e) => format!(r#"{{"error":"insert failed: {e}"}}"#), + } + }) + } + + /// Fetch a single row from a table. + /// + /// `where_json` can be: + /// - A JSON object: `{"id": 1}` → WHERE id = ? + /// - A string: `"id = 1 AND status = 'active'"` → raw WHERE clause + /// - An array: `["status = ? AND total > ?", "active", 100]` → parameterized + /// + /// Returns `{"data":{...}}` or `{"data":null}` or `{"error":"..."}`. + #[must_use] + pub fn db_fetch_one(&self, table: &str, where_json: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_readable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + let tenantable = self.is_tenantable_table(table); + let (sql, args) = Self::build_query_args(tenantable, table, &where_result, &opts); + + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).fetch_optional(pool).await }) + { + Ok(Some(row)) => { + let json = crate::plugins::rows_to_json(std::slice::from_ref(&row)); + format!(r#"{{"data":{json}}}"#) + } + Ok(None) => r#"{"data":null}"#.to_string(), + Err(e) => format!(r#"{{"error":"query failed: {e}"}}"#), + } + }) + } + + /// Fetch multiple rows from a table. + /// + /// `where_json` same as `db_fetch_one`. + /// `options_json` can include `order_by`, `limit`, `offset`, `tenant`. + /// + /// Returns `{"data":[...],"total":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_fetch_all(&self, table: &str, where_json: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_readable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + let tenantable = self.is_tenantable_table(table); + let (sql, args) = Self::build_query_args(tenantable, table, &where_result, &opts); + + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).fetch_all(pool).await }) { + Ok(rows) => { + let count = rows.len(); + let json = crate::plugins::rows_to_json(&rows); + format!(r#"{{"data":{json},"total":{count}}}"#) + } + Err(e) => format!(r#"{{"error":"query failed: {e}"}}"#), + } + }) + } + + /// Update rows in a table. + /// + /// `data_json` is a JSON object of columns to set. + /// `where_json` same as `db_fetch_one`. + /// + /// Returns `{"rows_affected":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_update( + &self, + table: &str, + data_json: &str, + where_json: &str, + options_json: &str, + ) -> String { + if let Err(e) = self.check_table_writable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let data: serde_json::Map = match serde_json::from_str(data_json) + { + Ok(d) => d, + Err(e) => return format!(r#"{{"error":"invalid data JSON: {e}"}}"#), + }; + if data.is_empty() { + return r#"{"error":"no columns to update"}"#.to_string(); + } + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + + let mut set_parts = Vec::new(); + let mut args = DbArguments::default(); + let mut idx = 1; + for (k, v) in &data { + set_parts.push(format!("{k} = {}", crate::db::dialect::ph(idx))); + idx += 1; + Self::add_param(&mut args, v); + } + + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let ph = crate::db::dialect::ph(idx); + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let sql = format!("UPDATE {table} SET {}{where_sql}", set_parts.join(", ")); + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await }) { + Ok(r) => format!(r#"{{"rows_affected":{}}}"#, r.rows_affected()), + Err(e) => format!(r#"{{"error":"update failed: {e}"}}"#), + } + }) + } + + /// Delete rows from a table. + /// + /// `where_json` same as `db_fetch_one`. + /// + /// Returns `{"rows_affected":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_delete(&self, table: &str, where_json: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_writable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + + let mut args = DbArguments::default(); + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let idx = where_result.params.len() + 1; + let ph = crate::db::dialect::ph(idx); + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let sql = format!("DELETE FROM {table}{where_sql}"); + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await }) { + Ok(r) => format!(r#"{{"rows_affected":{}}}"#, r.rows_affected()), + Err(e) => format!(r#"{{"error":"delete failed: {e}"}}"#), + } + }) + } + + /// Count rows in a table. + /// + /// `where_json` same as `db_fetch_one`. + /// + /// Returns `{"count":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_count(&self, table: &str, where_json: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_readable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + + let mut args = DbArguments::default(); + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let idx = where_result.params.len() + 1; + let ph = crate::db::dialect::ph(idx); + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let sql = format!("SELECT COUNT(*) as cnt FROM {table}{where_sql}"); + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { + let row: (i64,) = sqlx::query_as_with(&sql, args).fetch_one(pool).await?; + Ok::<_, sqlx::Error>(row.0) + }) { + Ok(count) => format!(r#"{{"count":{count}}}"#), + Err(e) => format!(r#"{{"error":"count failed: {e}"}}"#), + } + }) + } + + /// Atomically increment (or decrement) numeric columns. + /// + /// `columns_json` is a JSON object mapping column names to delta values, e.g. `{"view_count": 1}`. + /// `where_json` same as other CRUD functions. + /// `options_json` can include: + /// - `set`: JSON object of regular column-value pairs to SET alongside the increments. + /// - `min`: integer (default no clamp). When set, generates `CASE WHEN col > min - delta THEN col + delta ELSE min END`. + /// - `tenant`: tenant control. + /// + /// Returns `{"rows_affected":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_increment( + &self, + table: &str, + columns_json: &str, + where_json: &str, + options_json: &str, + ) -> String { + if let Err(e) = self.check_table_writable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let columns: serde_json::Map = + match serde_json::from_str(columns_json) { + Ok(c) => c, + Err(e) => return format!(r#"{{"error":"invalid columns JSON: {e}"}}"#), + }; + if columns.is_empty() { + return r#"{"error":"no columns to increment"}"#.to_string(); + } + + let opts = CrudOptions::parse(options_json); + let set_data: Option> = + serde_json::from_str(options_json) + .ok() + .and_then(|obj: serde_json::Map| obj.get("set").cloned()) + .and_then(|v| { + if v.is_object() { + v.as_object().cloned() + } else { + None + } + }); + + let min_value: Option = serde_json::from_str(options_json) + .ok() + .and_then(|obj: serde_json::Map| obj.get("min").cloned()) + .and_then(|v| v.as_i64()); + + let mut set_parts = Vec::new(); + let mut args = DbArguments::default(); + let mut idx = 1; + + for (col, delta) in &columns { + let delta_i64 = match delta.as_i64() { + Some(d) => d, + None => return format!(r#"{{"error":"delta for '{col}' must be an integer"}}"#), + }; + if let Some(min) = min_value { + let min_ph = crate::db::dialect::ph(idx); + idx += 1; + let delta_ph = crate::db::dialect::ph(idx); + idx += 1; + set_parts.push(format!("{col} = MAX({min_ph}, {col} + {delta_ph})")); + args.add(min).ok(); + args.add(delta_i64).ok(); + } else { + let ph = crate::db::dialect::ph(idx); + idx += 1; + set_parts.push(format!("{col} = {col} + {ph}")); + args.add(delta_i64).ok(); + } + } + + if let Some(ref set) = set_data { + for (k, v) in set { + let ph = crate::db::dialect::ph(idx); + idx += 1; + set_parts.push(format!("{k} = {ph}")); + Self::add_param(&mut args, v); + } + } + + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let ph = crate::db::dialect::ph(idx); + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let sql = format!("UPDATE {table} SET {}{where_sql}", set_parts.join(", ")); + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).execute(pool).await }) { + Ok(r) => format!(r#"{{"rows_affected":{}}}"#, r.rows_affected()), + Err(e) => format!(r#"{{"error":"increment failed: {e}"}}"#), + } + }) + } + + /// Compute SUM of a column. + /// + /// Returns `{"sum":}` or `{"error":"..."}`. + #[must_use] + pub fn db_sum( + &self, + table: &str, + column: &str, + where_json: &str, + options_json: &str, + ) -> String { + if let Err(e) = self.check_table_readable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let where_result = match Self::build_where_clause(where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + let opts = CrudOptions::parse(options_json); + + let mut args = DbArguments::default(); + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let idx = where_result.params.len() + 1; + let ph = crate::db::dialect::ph(idx); + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let sql = format!( + "SELECT CAST(COALESCE(SUM({column}), 0) AS TEXT) as total FROM {table}{where_sql}" + ); + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { + let row: (String,) = sqlx::query_as_with(&sql, args).fetch_one(pool).await?; + Ok::<_, sqlx::Error>(row.0) + }) { + Ok(total_str) => { + let total: f64 = total_str.parse().unwrap_or(0.0); + if total.fract() == 0.0 { + format!(r#"{{"sum":{}}}"#, total as i64) + } else { + format!(r#"{{"sum":{total}}}"#) + } + } + Err(e) => format!(r#"{{"error":"sum failed: {e}"}}"#), + } + }) + } + + /// GROUP BY with count and optional sum aggregates. + /// + /// `options_json` must include: + /// - `group_by`: string or array of strings — columns to GROUP BY. + /// - `count`: boolean (default false) — include COUNT(*). + /// - `sum`: string or array of strings — columns to SUM. + /// - `where`, `order_by`, `limit`, `tenant` — same as other CRUD functions. + /// + /// Returns `{"data":[...],"total":N}` or `{"error":"..."}`. + #[must_use] + pub fn db_group_by(&self, table: &str, options_json: &str) -> String { + if let Err(e) = self.check_table_readable(table) { + return format!(r#"{{"error":"{e}"}}"#); + } + let Ok(pool) = self.require_pool() else { + return r#"{"error":"no database access"}"#.to_string(); + }; + let obj: serde_json::Map = + match serde_json::from_str(options_json) { + Ok(o) => o, + Err(e) => return format!(r#"{{"error":"invalid options JSON: {e}"}}"#), + }; + + let group_by: Vec = match obj.get("group_by") { + Some(serde_json::Value::String(s)) => vec![s.clone()], + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + _ => return r#"{"error":"group_by is required"}"#.to_string(), + }; + if group_by.is_empty() { + return r#"{"error":"group_by cannot be empty"}"#.to_string(); + } + + let do_count = obj.get("count").and_then(|v| v.as_bool()).unwrap_or(false); + + let sum_cols: Vec = match obj.get("sum") { + Some(serde_json::Value::String(s)) => vec![s.clone()], + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + _ => Vec::new(), + }; + + let where_json = obj + .get("where") + .and_then(|v| serde_json::to_string(v).ok()) + .unwrap_or_default(); + let where_result = match Self::build_where_clause(&where_json) { + Ok(w) => w, + Err(e) => return format!(r#"{{"error":"{e}"}}"#), + }; + + let opts = CrudOptions::parse(options_json); + + let mut select_parts: Vec = group_by.clone(); + if do_count { + select_parts.push("CAST(COUNT(*) AS TEXT) as cnt".to_string()); + } + for col in &sum_cols { + select_parts.push(format!( + "CAST(COALESCE(SUM({col}), 0) AS TEXT) as sum_{col}" + )); + } + + let mut args = DbArguments::default(); + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + + let mut idx = where_result.params.len() + 1; + if self.is_tenantable_table(table) && !opts.tenant_is_disabled() { + let ph = crate::db::dialect::ph(idx); + idx += 1; + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + + let group_clause = group_by.join(", "); + let mut sql = format!( + "SELECT {} FROM {table}{where_sql} GROUP BY {group_clause}", + select_parts.join(", ") + ); + + if let Some(ref order_by) = opts.order_by { + sql.push_str(&format!(" ORDER BY {order_by}")); + } + if let Some(lim) = opts.limit { + let ph = crate::db::dialect::ph(idx); + sql.push_str(&format!(" LIMIT {ph}")); + args.add(lim as i64).ok(); + } + + let handle = tokio::runtime::Handle::current(); + tokio::task::block_in_place(|| { + match handle.block_on(async { sqlx::query_with(&sql, args).fetch_all(pool).await }) { + Ok(rows) => { + let count = rows.len(); + let json = crate::plugins::rows_to_json(&rows); + format!(r#"{{"data":{json},"total":{count}}}"#) + } + Err(e) => format!(r#"{{"error":"group_by failed: {e}"}}"#), + } + }) + } + + // ── Where clause parsing ───────────────────────────────────── + + fn build_where_clause(where_json: &str) -> Result { + let trimmed = where_json.trim(); + if trimmed.is_empty() || trimmed == "null" || trimmed == "{}" { + return Ok(WhereResult { + clause: String::new(), + params: Vec::new(), + }); + } + + // Try array form: ["col = ? AND col2 = ?", val1, val2] + if trimmed.starts_with('[') { + let arr: Vec = + serde_json::from_str(trimmed).map_err(|e| format!("invalid where array: {e}"))?; + if arr.is_empty() { + return Ok(WhereResult { + clause: String::new(), + params: Vec::new(), + }); + } + let clause = arr[0] + .as_str() + .ok_or_else(|| "where array first element must be a SQL string".to_string())?; + let params = arr[1..].to_vec(); + return Ok(WhereResult { + clause: clause.to_string(), + params, + }); + } + + // Try object form: {"col1": val1, "col2": val2} + if trimmed.starts_with('{') { + let obj: serde_json::Map = + serde_json::from_str(trimmed).map_err(|e| format!("invalid where object: {e}"))?; + if obj.is_empty() { + return Ok(WhereResult { + clause: String::new(), + params: Vec::new(), + }); + } + let mut parts = Vec::new(); + let mut params = Vec::new(); + for (i, (k, _v)) in obj.iter().enumerate() { + parts.push(format!("{k} = {}", crate::db::dialect::ph(i + 1))); + } + for (_, v) in obj.iter() { + params.push(v.clone()); + } + return Ok(WhereResult { + clause: parts.join(" AND "), + params, + }); + } + + // String form: raw SQL + Ok(WhereResult { + clause: trimmed.to_string(), + params: Vec::new(), + }) + } + + fn build_query_args( + tenantable: bool, + table: &str, + where_result: &WhereResult, + opts: &CrudOptions, + ) -> (String, DbArguments<'static>) { + let mut args = DbArguments::default(); + let mut where_sql = String::new(); + if !where_result.clause.is_empty() { + where_sql = format!(" WHERE {}", where_result.clause); + } + for p in &where_result.params { + Self::add_param(&mut args, p); + } + let mut idx = where_result.params.len() + 1; + if tenantable && !opts.tenant_is_disabled() { + let ph = crate::db::dialect::ph(idx); + idx += 1; + let connector = if where_sql.is_empty() { + " WHERE" + } else { + " AND" + }; + where_sql.push_str(&format!("{connector} tenant_id = {ph}")); + let tid = opts + .tenant_value_owned() + .unwrap_or_else(|| crate::constants::DEFAULT_TENANT.to_string()); + args.add(tid).ok(); + } + if let Some(ref order_by) = opts.order_by { + where_sql.push_str(&format!(" ORDER BY {order_by}")); + } + if let Some(lim) = opts.limit { + let ph = crate::db::dialect::ph(idx); + idx += 1; + where_sql.push_str(&format!(" LIMIT {ph}")); + args.add(lim as i64).ok(); + } + if let Some(off) = opts.offset { + let ph = crate::db::dialect::ph(idx); + where_sql.push_str(&format!(" OFFSET {ph}")); + args.add(off as i64).ok(); + } + (format!("SELECT * FROM {table}{where_sql}"), args) + } + /// Read a file from the virtual file system pub fn vfs_read(&self, path: &str) -> Result { let vfs = VirtualFs::new(&self.config, &self.plugin_id, &self.permissions); @@ -1237,4 +2074,709 @@ mod tests { ); ctx.cleanup_tx(); } + + // ── High-level CRUD tests ────────────────────────────────────── + + fn make_crud_ctx(pool: &Pool) -> HostContext { + let config = make_test_config(); + let perms = Permissions { + database: vec!["tags".into(), "categories".into(), "posts".into()], + ..Permissions::default() + }; + HostContext::new( + "test", + config, + "crud-test".into(), + perms, + Some(pool.clone()), + ) + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_insert_and_fetch_one() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let result = ctx.db_insert( + "tags", + r#"{"document_id":"t1","name":"Rust","slug":"rust"}"#, + "{}", + ); + assert!(result.contains(r#""rows_affected":1"#), "insert: {result}"); + + let found = ctx.db_fetch_one("tags", r#"{"document_id":"t1"}"#, "{}"); + assert!(found.contains(r#""name":"Rust""#), "fetch_one: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_fetch_one_not_found() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let found = ctx.db_fetch_one("tags", r#"{"document_id":"nonexistent"}"#, "{}"); + assert!(found.contains(r#""data":null"#), "not found: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_fetch_one_string_where() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"t2","name":"Go","slug":"go"}"#, + "{}", + ); + + let found = ctx.db_fetch_one("tags", r#"name = 'Go'"#, "{}"); + assert!(found.contains(r#""slug":"go""#), "string where: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_fetch_one_array_where() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"t3","name":"Python","slug":"python"}"#, + "{}", + ); + + let found = ctx.db_fetch_one("tags", r#"["name = ?", "Python"]"#, "{}"); + assert!( + found.contains(r#""document_id":"t3""#), + "array where: {found}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_fetch_all_with_order_and_limit() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"a1","name":"A","slug":"a"}"#, + "{}", + ); + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"b2","name":"B","slug":"b"}"#, + "{}", + ); + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"c3","name":"C","slug":"c"}"#, + "{}", + ); + + let result = ctx.db_fetch_all("tags", "{}", r#"{"order_by":"name DESC","limit":2}"#); + assert!(result.contains(r#""total":2"#), "fetch_all limit: {result}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_update() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"u1","name":"Old","slug":"old"}"#, + "{}", + ); + + let result = ctx.db_update("tags", r#"{"name":"New"}"#, r#"{"document_id":"u1"}"#, "{}"); + assert!(result.contains(r#""rows_affected":1"#), "update: {result}"); + + let found = ctx.db_fetch_one("tags", r#"{"document_id":"u1"}"#, "{}"); + assert!(found.contains(r#""name":"New""#), "after update: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_update_empty_data() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let result = ctx.db_update("tags", "{}", "{}", "{}"); + assert!(result.contains("no columns"), "empty data: {result}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_delete() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"d1","name":"Delete","slug":"del"}"#, + "{}", + ); + + let result = ctx.db_delete("tags", r#"{"document_id":"d1"}"#, "{}"); + assert!(result.contains(r#""rows_affected":1"#), "delete: {result}"); + + let found = ctx.db_fetch_one("tags", r#"{"document_id":"d1"}"#, "{}"); + assert!(found.contains(r#""data":null"#), "after delete: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_count() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"c1","name":"Count1","slug":"c1"}"#, + "{}", + ); + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"c2","name":"Count2","slug":"c2"}"#, + "{}", + ); + + let result = ctx.db_count("tags", "{}", "{}"); + assert!(result.contains(r#""count":2"#), "count: {result}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_count_with_where() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"cw1","name":"Go","slug":"go"}"#, + "{}", + ); + let _ = ctx.db_insert( + "tags", + r#"{"document_id":"cw2","name":"Rust","slug":"rust"}"#, + "{}", + ); + + let result = ctx.db_count("tags", r#"["name = ?", "Rust"]"#, "{}"); + assert!( + result.contains(r#""count":1"#), + "count with where: {result}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_crud_no_pool() { + let config = make_test_config(); + let perms = Permissions { + database: vec!["tags".into()], + ..Permissions::default() + }; + let ctx = HostContext::new("test", config, "p1".into(), perms, None); + + assert!(ctx.db_insert("tags", "{}", "{}").contains("no database")); + assert!(ctx.db_fetch_one("tags", "{}", "{}").contains("no database")); + assert!(ctx.db_fetch_all("tags", "{}", "{}").contains("no database")); + assert!( + ctx.db_update("tags", "{}", "{}", "{}") + .contains("no database") + ); + assert!(ctx.db_delete("tags", "{}", "{}").contains("no database")); + assert!(ctx.db_count("tags", "{}", "{}").contains("no database")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_crud_no_permission() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let config = make_test_config(); + let ctx = HostContext::new( + "test", + config, + "p1".into(), + Permissions::default(), + Some(pool), + ); + + assert!( + ctx.db_insert("tags", "{}", "{}") + .contains("no write permission") + ); + assert!( + ctx.db_fetch_one("tags", "{}", "{}") + .contains("no read permission") + ); + assert!( + ctx.db_fetch_all("tags", "{}", "{}") + .contains("no read permission") + ); + assert!( + ctx.db_update("tags", "{}", "{}", "{}") + .contains("no write permission") + ); + assert!( + ctx.db_delete("tags", "{}", "{}") + .contains("no write permission") + ); + assert!( + ctx.db_count("tags", "{}", "{}") + .contains("no read permission") + ); + } + + // ── db_increment / db_sum / db_group_by tests ──────────────── + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_simple() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"p1","name":"Test","slug":"test","sort_order":"0"}"#, + "{}", + ); + + let r = ctx.db_increment( + "categories", + r#"{"sort_order":1}"#, + r#"{"document_id":"p1"}"#, + "{}", + ); + assert!(r.contains(r#""rows_affected":1"#), "increment: {r}"); + + let s = ctx.db_sum("categories", "sort_order", r#"{"document_id":"p1"}"#, "{}"); + assert!(s.contains(r#""sum":1"#), "after increment sum: {s}"); + + let r2 = ctx.db_increment( + "categories", + r#"{"sort_order":1}"#, + r#"{"document_id":"p1"}"#, + "{}", + ); + assert!(r2.contains(r#""rows_affected":1"#)); + + let s2 = ctx.db_sum("categories", "sort_order", r#"{"document_id":"p1"}"#, "{}"); + assert!(s2.contains(r#""sum":2"#), "after 2nd sum: {s2}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_negative_delta() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"p2","name":"Dec","slug":"dec","sort_order":"5"}"#, + "{}", + ); + + let r = ctx.db_increment( + "categories", + r#"{"sort_order":-1}"#, + r#"{"document_id":"p2"}"#, + "{}", + ); + assert!(r.contains(r#""rows_affected":1"#), "decrement: {r}"); + + let s = ctx.db_sum("categories", "sort_order", r#"{"document_id":"p2"}"#, "{}"); + assert!(s.contains(r#""sum":4"#), "after decrement sum: {s}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_with_min_clamp() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"p3","name":"Clamp","slug":"clamp","sort_order":"1"}"#, + "{}", + ); + + let r = ctx.db_increment( + "categories", + r#"{"sort_order":-5}"#, + r#"{"document_id":"p3"}"#, + r#"{"min":0}"#, + ); + assert!(r.contains(r#""rows_affected":1"#), "clamp: {r}"); + + let s = ctx.db_sum("categories", "sort_order", r#"{"document_id":"p3"}"#, "{}"); + assert!(s.contains(r#""sum":0"#), "clamped to 0 sum: {s}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_with_set() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"p4","name":"Set","slug":"set","sort_order":"0"}"#, + "{}", + ); + + let r = ctx.db_increment( + "categories", + r#"{"sort_order":1}"#, + r#"{"document_id":"p4"}"#, + r#"{"set":{"name":"Updated"}}"#, + ); + assert!(r.contains(r#""rows_affected":1"#), "increment+set: {r}"); + + let s = ctx.db_sum("categories", "sort_order", r#"{"document_id":"p4"}"#, "{}"); + assert!(s.contains(r#""sum":1"#), "incremented sum: {s}"); + + let found = ctx.db_fetch_one("categories", r#"{"document_id":"p4"}"#, "{}"); + assert!(found.contains(r#""name":"Updated""#), "set col: {found}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_no_pool() { + let config = make_test_config(); + let perms = Permissions { + database: vec!["categories".into()], + ..Permissions::default() + }; + let ctx = HostContext::new("test", config, "p1".into(), perms, None); + let r = ctx.db_increment("categories", r#"{"sort_order":1}"#, "{}", "{}"); + assert!(r.contains("no database access")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_no_permission() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let config = make_test_config(); + let ctx = HostContext::new( + "test", + config, + "p1".into(), + Permissions::default(), + Some(pool), + ); + let r = ctx.db_increment("categories", r#"{"sort_order":1}"#, "{}", "{}"); + assert!(r.contains("no write permission")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_sum_basic() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"s1","name":"A","slug":"sa","sort_order":"3"}"#, + "{}", + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"s2","name":"B","slug":"sb","sort_order":"7"}"#, + "{}", + ); + + let r = ctx.db_sum( + "categories", + "sort_order", + r#"{"tenant_id":"default"}"#, + "{}", + ); + assert!(r.contains(r#""sum":10"#), "sum: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_sum_empty() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let r = ctx.db_sum("categories", "sort_order", "{}", "{}"); + assert!(r.contains(r#""sum":0"#), "sum empty: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_sum_no_pool() { + let config = make_test_config(); + let perms = Permissions { + database: vec!["categories".into()], + ..Permissions::default() + }; + let ctx = HostContext::new("test", config, "p1".into(), perms, None); + let r = ctx.db_sum("categories", "sort_order", "{}", "{}"); + assert!(r.contains("no database access")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_count() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"g1","name":"Rust1","slug":"rust1","tenant_id":"t1"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"g2","name":"Go","slug":"go","tenant_id":"t2"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"g3","name":"Rust2","slug":"rust2","tenant_id":"t1"}"#, + r#"{"tenant":"disabled"}"#, + ); + + let r = ctx.db_group_by( + "categories", + r#"{"group_by":"tenant_id","count":true,"order_by":"cnt DESC","tenant":false}"#, + ); + assert!(r.contains(r#""total":2"#), "group_by count: {r}"); + assert!(r.contains(r#""tenant_id":"t1""#), "group_by t1: {r}"); + assert!(r.contains(r#""cnt":2"#), "group_by cnt: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_with_sum() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"gs1","name":"A1","slug":"gsa","sort_order":"3","tenant_id":"grpA"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"gs2","name":"A2","slug":"gsb","sort_order":"7","tenant_id":"grpA"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"gs3","name":"B1","slug":"gsc","sort_order":"2","tenant_id":"grpB"}"#, + r#"{"tenant":"disabled"}"#, + ); + + let r = ctx.db_group_by( + "categories", + r#"{"group_by":"tenant_id","count":true,"sum":"sort_order","order_by":"sum_sort_order DESC","tenant":false}"#, + ); + assert!(r.contains(r#""total":2"#), "group_by sum total: {r}"); + assert!( + r.contains(r#""sum_sort_order":10"#), + "group_by sum grpA: {r}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_with_where() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"w1","name":"W1","slug":"wa","tenant_id":"tA"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"w2","name":"W2","slug":"wb","tenant_id":"tA"}"#, + r#"{"tenant":"disabled"}"#, + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"w3","name":"W3","slug":"wc","tenant_id":"tB"}"#, + r#"{"tenant":"disabled"}"#, + ); + + let r = ctx.db_group_by( + "categories", + r#"{"group_by":"tenant_id","count":true,"where":{"tenant_id":"tA"},"tenant":false}"#, + ); + assert!(r.contains(r#""total":1"#), "group_by where: {r}"); + assert!(r.contains(r#""cnt":2"#), "group_by where cnt: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_with_limit() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"l1","name":"X","slug":"lx"}"#, + "{}", + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"l2","name":"Y","slug":"ly"}"#, + "{}", + ); + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"l3","name":"Z","slug":"lz"}"#, + "{}", + ); + + let r = ctx.db_group_by( + "categories", + r#"{"group_by":"name","count":true,"limit":2,"order_by":"name ASC"}"#, + ); + assert!(r.contains(r#""total":2"#), "group_by limit: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_missing_field() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let r = ctx.db_group_by("categories", r#"{"count":true}"#); + assert!(r.contains("group_by is required"), "missing: {r}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_no_pool() { + let config = make_test_config(); + let perms = Permissions { + database: vec!["categories".into()], + ..Permissions::default() + }; + let ctx = HostContext::new("test", config, "p1".into(), perms, None); + let r = ctx.db_group_by("categories", r#"{"group_by":"name","count":true}"#); + assert!(r.contains("no database access")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_group_by_no_permission() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let config = make_test_config(); + let ctx = HostContext::new( + "test", + config, + "p1".into(), + Permissions::default(), + Some(pool), + ); + let r = ctx.db_group_by("categories", r#"{"group_by":"name","count":true}"#); + assert!(r.contains("no read permission")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn db_increment_multi_column_with_min() { + let pool = crate::db::Pool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(crate::db::schema::SCHEMA_SQL) + .execute(&pool) + .await + .unwrap(); + let ctx = make_crud_ctx(&pool); + + let _ = ctx.db_insert( + "categories", + r#"{"document_id":"m1","name":"Multi","slug":"multi","sort_order":"2"}"#, + "{}", + ); + + let r = ctx.db_increment( + "categories", + r#"{"sort_order":-10}"#, + r#"{"document_id":"m1"}"#, + r#"{"min":0}"#, + ); + assert!(r.contains(r#""rows_affected":1"#), "multi clamp: {r}"); + + let s = ctx.db_sum("categories", "sort_order", r#"{"document_id":"m1"}"#, "{}"); + assert!(s.contains(r#""sum":0"#), "clamped sum: {s}"); + } } diff --git a/src/plugins/js_host.rs b/src/plugins/js_host.rs index f466e511..d0134462 100644 --- a/src/plugins/js_host.rs +++ b/src/plugins/js_host.rs @@ -98,6 +98,85 @@ pub fn register_host_functions( let db_rollback_fn = Function::new(ctx.clone(), move || -> String { hc.db_rollback() })?; host.set("dbRollback", db_rollback_fn)?; + let hc = host_ctx.clone(); + let db_insert_fn = Function::new( + ctx.clone(), + move |table: String, data: String, options: String| -> String { + hc.db_insert(&table, &data, &options) + }, + )?; + host.set("dbInsert", db_insert_fn)?; + + let hc = host_ctx.clone(); + let db_fetch_one_fn = Function::new( + ctx.clone(), + move |table: String, r#where: String, options: String| -> String { + hc.db_fetch_one(&table, &r#where, &options) + }, + )?; + host.set("dbFetchOne", db_fetch_one_fn)?; + + let hc = host_ctx.clone(); + let db_fetch_all_fn = Function::new( + ctx.clone(), + move |table: String, r#where: String, options: String| -> String { + hc.db_fetch_all(&table, &r#where, &options) + }, + )?; + host.set("dbFetchAll", db_fetch_all_fn)?; + + let hc = host_ctx.clone(); + let db_update_fn = Function::new( + ctx.clone(), + move |table: String, data: String, r#where: String, options: String| -> String { + hc.db_update(&table, &data, &r#where, &options) + }, + )?; + host.set("dbUpdate", db_update_fn)?; + + let hc = host_ctx.clone(); + let db_delete_fn = Function::new( + ctx.clone(), + move |table: String, r#where: String, options: String| -> String { + hc.db_delete(&table, &r#where, &options) + }, + )?; + host.set("dbDelete", db_delete_fn)?; + + let hc = host_ctx.clone(); + let db_count_fn = Function::new( + ctx.clone(), + move |table: String, r#where: String, options: String| -> String { + hc.db_count(&table, &r#where, &options) + }, + )?; + host.set("dbCount", db_count_fn)?; + + let hc = host_ctx.clone(); + let db_increment_fn = Function::new( + ctx.clone(), + move |table: String, columns: String, r#where: String, options: String| -> String { + hc.db_increment(&table, &columns, &r#where, &options) + }, + )?; + host.set("dbIncrement", db_increment_fn)?; + + let hc = host_ctx.clone(); + let db_sum_fn = Function::new( + ctx.clone(), + move |table: String, column: String, r#where: String, options: String| -> String { + hc.db_sum(&table, &column, &r#where, &options) + }, + )?; + host.set("dbSum", db_sum_fn)?; + + let hc = host_ctx.clone(); + let db_group_by_fn = Function::new( + ctx.clone(), + move |table: String, options: String| -> String { hc.db_group_by(&table, &options) }, + )?; + host.set("dbGroupBy", db_group_by_fn)?; + let hc = host_ctx.clone(); let vfs_read_fn = Function::new(ctx.clone(), move |path: String| -> Option { hc.vfs_read(&path).ok() diff --git a/src/plugins/lua_host.rs b/src/plugins/lua_host.rs index 4222229c..859debc2 100644 --- a/src/plugins/lua_host.rs +++ b/src/plugins/lua_host.rs @@ -113,6 +113,94 @@ pub fn register_host_functions( })?; host.set("dbRollback", db_rollback_fn)?; + let hc = host_ctx.clone(); + let db_insert_fn = lua.create_function( + move |lua, (table, data, options): (String, String, String)| { + Ok(mlua::Value::String( + lua.create_string(hc.db_insert(&table, &data, &options))?, + )) + }, + )?; + host.set("dbInsert", db_insert_fn)?; + + let hc = host_ctx.clone(); + let db_fetch_one_fn = lua.create_function( + move |lua, (table, r#where, options): (String, String, String)| { + Ok(mlua::Value::String(lua.create_string( + hc.db_fetch_one(&table, &r#where, &options), + )?)) + }, + )?; + host.set("dbFetchOne", db_fetch_one_fn)?; + + let hc = host_ctx.clone(); + let db_fetch_all_fn = lua.create_function( + move |lua, (table, r#where, options): (String, String, String)| { + Ok(mlua::Value::String(lua.create_string( + hc.db_fetch_all(&table, &r#where, &options), + )?)) + }, + )?; + host.set("dbFetchAll", db_fetch_all_fn)?; + + let hc = host_ctx.clone(); + let db_update_fn = lua.create_function( + move |lua, (table, data, r#where, options): (String, String, String, String)| { + Ok(mlua::Value::String(lua.create_string( + hc.db_update(&table, &data, &r#where, &options), + )?)) + }, + )?; + host.set("dbUpdate", db_update_fn)?; + + let hc = host_ctx.clone(); + let db_delete_fn = lua.create_function( + move |lua, (table, r#where, options): (String, String, String)| { + Ok(mlua::Value::String( + lua.create_string(hc.db_delete(&table, &r#where, &options))?, + )) + }, + )?; + host.set("dbDelete", db_delete_fn)?; + + let hc = host_ctx.clone(); + let db_count_fn = lua.create_function( + move |lua, (table, r#where, options): (String, String, String)| { + Ok(mlua::Value::String( + lua.create_string(hc.db_count(&table, &r#where, &options))?, + )) + }, + )?; + host.set("dbCount", db_count_fn)?; + + let hc = host_ctx.clone(); + let db_increment_fn = lua.create_function( + move |lua, (table, columns, r#where, options): (String, String, String, String)| { + Ok(mlua::Value::String(lua.create_string( + hc.db_increment(&table, &columns, &r#where, &options), + )?)) + }, + )?; + host.set("dbIncrement", db_increment_fn)?; + + let hc = host_ctx.clone(); + let db_sum_fn = lua.create_function( + move |lua, (table, column, r#where, options): (String, String, String, String)| { + Ok(mlua::Value::String(lua.create_string( + hc.db_sum(&table, &column, &r#where, &options), + )?)) + }, + )?; + host.set("dbSum", db_sum_fn)?; + + let hc = host_ctx.clone(); + let db_group_by_fn = lua.create_function(move |lua, (table, options): (String, String)| { + Ok(mlua::Value::String( + lua.create_string(hc.db_group_by(&table, &options))?, + )) + })?; + host.set("dbGroupBy", db_group_by_fn)?; + let hc = host_ctx.clone(); let vfs_read_fn = lua.create_function(move |lua, path: String| match hc.vfs_read(&path) { Ok(content) => Ok(mlua::Value::String(lua.create_string(&content)?)), diff --git a/src/plugins/rhai_host.rs b/src/plugins/rhai_host.rs index 61af80da..823d84a2 100644 --- a/src/plugins/rhai_host.rs +++ b/src/plugins/rhai_host.rs @@ -92,6 +92,75 @@ pub fn register_host_functions( let hc = host_ctx.clone(); engine.register_fn("dbRollback", move || -> String { hc.db_rollback() }); + let hc = host_ctx.clone(); + engine.register_fn( + "dbInsert", + move |table: &str, data: &str, options: &str| -> String { + hc.db_insert(table, data, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbFetchOne", + move |table: &str, r#where: &str, options: &str| -> String { + hc.db_fetch_one(table, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbFetchAll", + move |table: &str, r#where: &str, options: &str| -> String { + hc.db_fetch_all(table, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbUpdate", + move |table: &str, data: &str, r#where: &str, options: &str| -> String { + hc.db_update(table, data, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbDelete", + move |table: &str, r#where: &str, options: &str| -> String { + hc.db_delete(table, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbCount", + move |table: &str, r#where: &str, options: &str| -> String { + hc.db_count(table, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbIncrement", + move |table: &str, columns: &str, r#where: &str, options: &str| -> String { + hc.db_increment(table, columns, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn( + "dbSum", + move |table: &str, column: &str, r#where: &str, options: &str| -> String { + hc.db_sum(table, column, r#where, options) + }, + ); + + let hc = host_ctx.clone(); + engine.register_fn("dbGroupBy", move |table: &str, options: &str| -> String { + hc.db_group_by(table, options) + }); + let hc = host_ctx.clone(); engine.register_fn("vfsRead", move |path: &str| -> rhai::Dynamic { match hc.vfs_read(path) { @@ -294,6 +363,12 @@ mod tests { let _: String = engine.eval(r#"dbCommit()"#).unwrap(); let _: String = engine.eval(r#"dbRollback()"#).unwrap(); let _: String = engine.eval(r#"dbPh(1)"#).unwrap(); + let _: String = engine.eval(r#"dbInsert("t", "{}", "{}")"#).unwrap(); + let _: String = engine.eval(r#"dbFetchOne("t", "{}", "{}")"#).unwrap(); + let _: String = engine.eval(r#"dbFetchAll("t", "{}", "{}")"#).unwrap(); + let _: String = engine.eval(r#"dbUpdate("t", "{}", "{}", "{}")"#).unwrap(); + let _: String = engine.eval(r#"dbDelete("t", "{}", "{}")"#).unwrap(); + let _: String = engine.eval(r#"dbCount("t", "{}", "{}")"#).unwrap(); let _ = engine.eval::(r#"vfsRead("f")"#).unwrap(); let _: bool = engine.eval(r#"vfsWrite("f", "c")"#).unwrap(); let _: bool = engine.eval(r#"vfsDelete("f")"#).unwrap();