refactor: replace Prisma ORM runtime with Drizzle ORM facade
- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
This commit is contained in:
1 parent
9d1c71d926
commit
56061e41d4
52 files changed
+5825
-748
No files matched your search
+18
-113
@@ -1,45 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "./admin-helpers";
|
||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic Prisma model access requires it
|
||||
type PrismaModel = any;
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Prisma model name (e.g. 'user', 'ban', 'room') */
|
||||
model: string;
|
||||
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
|
||||
searchFields: string[];
|
||||
/** Default sort when no sort param provided. Default: { id: 'desc' } */
|
||||
defaultSort?: Record<string, "asc" | "desc">;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/** Prisma include clause for relations */
|
||||
include?: Record<string, unknown>;
|
||||
/** Prisma select clause (mutually exclusive with include) */
|
||||
select?: Record<string, unknown>;
|
||||
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
|
||||
baseWhere?: Record<string, unknown>;
|
||||
/**
|
||||
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
|
||||
* the raw string value from the URL and returns the WHERE fragment to merge.
|
||||
* Returning null/undefined drops the filter.
|
||||
*
|
||||
* @example
|
||||
* filterMap: {
|
||||
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
|
||||
* filter_online: (v) => ({ online: v }),
|
||||
* }
|
||||
* Runs the actual data query (Drizzle). Receives parsed list params and
|
||||
* returns the rows + total count for the current page.
|
||||
*/
|
||||
filterMap?: Record<
|
||||
string,
|
||||
(value: string) => Record<string, unknown> | null | undefined
|
||||
>;
|
||||
/** Transform raw Prisma row to the shape needed by the table component */
|
||||
mapRow?: (row: PrismaModel) => TRow;
|
||||
fetch: (args: {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
rawParams: Record<string, string>;
|
||||
}) => Promise<{ rows: TRow[]; total: number }>;
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
@@ -53,35 +30,12 @@ interface AdminListResult<TRow> {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested Prisma WHERE condition from a dot-notation field path.
|
||||
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
|
||||
* e.g. 'username' => { username: { contains: search } }
|
||||
*/
|
||||
function buildFieldCondition(
|
||||
fieldPath: string,
|
||||
search: string,
|
||||
): Record<string, unknown> {
|
||||
const parts = fieldPath.split(".");
|
||||
if (parts.length === 1) {
|
||||
return { [parts[0]]: { contains: search } };
|
||||
}
|
||||
// Build nested object from right to left
|
||||
let result: Record<string, unknown> = {
|
||||
[parts[parts.length - 1]]: { contains: search },
|
||||
};
|
||||
for (let i = parts.length - 2; i >= 0; i--) {
|
||||
result = { [parts[i]]: result };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic admin list data fetcher.
|
||||
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
|
||||
* Prisma query with pagination, and row mapping.
|
||||
* Centralizes the common pattern: auth check, param parsing, pagination and
|
||||
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
|
||||
*/
|
||||
export async function fetchAdminList<TRow = PrismaModel>(
|
||||
export async function fetchAdminList<TRow>(
|
||||
config: AdminListConfig<TRow>,
|
||||
paramsPromise: Promise<{ locale: string }>,
|
||||
searchParamsPromise: Promise<Record<string, string>>,
|
||||
@@ -99,64 +53,15 @@ export async function fetchAdminList<TRow = PrismaModel>(
|
||||
const perPage = config.defaultPerPage
|
||||
? Number(rawParams.perPage) || config.defaultPerPage
|
||||
: parsed.perPage;
|
||||
const { search, page, sort, order } = parsed;
|
||||
|
||||
// Build WHERE clause
|
||||
const searchOr =
|
||||
search && config.searchFields.length > 0
|
||||
? config.searchFields.map((field) => buildFieldCondition(field, search))
|
||||
: [];
|
||||
// Exact numeric id match (furni / rooms / items lookup by id).
|
||||
if (search && /^\d+$/.test(search)) {
|
||||
const asId = Number(search);
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
searchOr.push({ id: asId });
|
||||
}
|
||||
}
|
||||
const searchWhere = searchOr.length > 0 ? { OR: searchOr } : {};
|
||||
const { rows, total } = await config.fetch({
|
||||
search: parsed.search,
|
||||
page: parsed.page,
|
||||
perPage,
|
||||
rawParams,
|
||||
});
|
||||
|
||||
// Apply URL filter_* params via filterMap
|
||||
const filterWhere: Record<string, unknown> = {};
|
||||
if (config.filterMap) {
|
||||
for (const [paramKey, build] of Object.entries(config.filterMap)) {
|
||||
const value = rawParams[paramKey];
|
||||
if (!value) continue;
|
||||
const fragment = build(value);
|
||||
if (fragment) Object.assign(filterWhere, fragment);
|
||||
}
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(config.baseWhere ?? {}),
|
||||
...filterWhere,
|
||||
...searchWhere,
|
||||
};
|
||||
|
||||
// Build orderBy
|
||||
const orderBy = sort
|
||||
? { [sort]: order }
|
||||
: (config.defaultSort ?? { id: "desc" as const });
|
||||
|
||||
// Access Prisma model dynamically
|
||||
const delegate = (prisma as PrismaModel)[config.model];
|
||||
|
||||
const queryArgs: PrismaModel = {
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
};
|
||||
|
||||
if (config.include) queryArgs.include = config.include;
|
||||
if (config.select) queryArgs.select = config.select;
|
||||
|
||||
const [rawRows, total] = await Promise.all([
|
||||
delegate.findMany(queryArgs),
|
||||
delegate.count({ where }),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows;
|
||||
const pagination = calcPagination(total, parsed.page, perPage);
|
||||
|
||||
return {
|
||||
rows,
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("ACL management contract", () => {
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("aclModelPermission");
|
||||
expect(source).toContain("AclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export interface StaffActivityFilters {
|
||||
q?: string;
|
||||
staffId?: number | null;
|
||||
@@ -9,8 +7,8 @@ export interface StaffActivityFilters {
|
||||
|
||||
export function buildStaffActivityWhere(
|
||||
filters: StaffActivityFilters,
|
||||
): Prisma.StaffActivitiesWhereInput {
|
||||
const where: Prisma.StaffActivitiesWhereInput = {};
|
||||
): any {
|
||||
const where: any = {};
|
||||
if (filters.q?.trim())
|
||||
where.OR = [
|
||||
{ action: { contains: filters.q.trim() } },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveConnectionLimit } from "./db-pool";
|
||||
|
||||
describe("resolveConnectionLimit", () => {
|
||||
it("caps the pool during production build phases", () => {
|
||||
expect(resolveConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||
expect(resolveConnectionLimit(40, "phase-production-compile")).toBe(5);
|
||||
expect(resolveConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the configured pool outside build", () => {
|
||||
expect(resolveConnectionLimit(40, undefined)).toBe(40);
|
||||
expect(resolveConnectionLimit(40, "phase-development-server")).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Cap pool size during `next build` so N workers × pool don't exhaust MySQL.
|
||||
* Kept free of Prisma/env imports so unit tests can exercise it in isolation.
|
||||
* Kept free of driver/env imports so unit tests can exercise it in isolation.
|
||||
*/
|
||||
export function resolvePrismaConnectionLimit(
|
||||
export function resolveConnectionLimit(
|
||||
poolSize: number,
|
||||
phase = process.env.NEXT_PHASE,
|
||||
): number {
|
||||
@@ -0,0 +1,62 @@
|
||||
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import * as relations from "@/db/relations";
|
||||
import * as schema from "@/db/schema";
|
||||
import { env } from "@/env";
|
||||
import { resolveConnectionLimit } from "@/lib/db-pool";
|
||||
|
||||
const fullSchema = { ...schema, ...relations };
|
||||
|
||||
const globalForDb = globalThis as unknown as {
|
||||
db?: MySql2Database<typeof fullSchema>;
|
||||
};
|
||||
|
||||
function createDb(): MySql2Database<typeof fullSchema> {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const isBuild =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_PHASE === "phase-production-compile";
|
||||
const connectionLimit = resolveConnectionLimit(env.DATABASE_POOL_SIZE);
|
||||
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
||||
const connectTimeout = isBuild
|
||||
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
||||
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
charset: "utf8mb4",
|
||||
waitForConnections: true,
|
||||
connectionLimit,
|
||||
queueLimit: 0,
|
||||
connectTimeout,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
// Keep BIGINT precision; safe values come back as numbers, huge ones as
|
||||
// strings (drizzle bigint mode maps both to JS bigint).
|
||||
supportBigNumbers: true,
|
||||
});
|
||||
|
||||
return drizzle(pool, {
|
||||
schema: fullSchema,
|
||||
mode: "default",
|
||||
logger:
|
||||
env.NODE_ENV === "development"
|
||||
? {
|
||||
logQuery(query, params) {
|
||||
console.log("[db]", query, params);
|
||||
},
|
||||
}
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const db = globalForDb.db ?? createDb();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForDb.db = db;
|
||||
|
||||
export type Db = MySql2Database<typeof fullSchema>;
|
||||
|
||||
export * from "@/db/schema";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePrismaConnectionLimit } from "./prisma-pool";
|
||||
|
||||
describe("resolvePrismaConnectionLimit", () => {
|
||||
it("caps the pool during production build phases", () => {
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5);
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe(
|
||||
5,
|
||||
);
|
||||
expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the configured pool outside build", () => {
|
||||
expect(resolvePrismaConnectionLimit(40, undefined)).toBe(40);
|
||||
expect(resolvePrismaConnectionLimit(40, "phase-development-server")).toBe(
|
||||
40,
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-36
@@ -1,37 +1,6 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { resolvePrismaConnectionLimit } from "@/lib/prisma-pool";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { makePrisma, Prisma as PrismaHelper } from "@/lib/prisma-facade";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const isBuild =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_PHASE === "phase-production-compile";
|
||||
const connectionLimit = resolvePrismaConnectionLimit(env.DATABASE_POOL_SIZE);
|
||||
// Fail fast during SSG so Next can retry/fallback instead of hanging 30s+.
|
||||
const connectTimeout = isBuild
|
||||
? Math.min(env.DATABASE_CONNECT_TIMEOUT_MS, 5_000)
|
||||
: env.DATABASE_CONNECT_TIMEOUT_MS;
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit,
|
||||
connectTimeout,
|
||||
acquireTimeout: connectTimeout,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
export const prisma = makePrisma(db) as unknown as PrismaClient;
|
||||
export { PrismaHelper as Prisma };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
|
||||
@@ -139,11 +139,11 @@ export async function loadCatalogItemsData(
|
||||
// Load items via raw query to work around pageId Int vs VARCHAR mismatch
|
||||
const pageIdStr = String(pageId);
|
||||
// CAST: live Habbo DBs often store page_id as VARCHAR while Prisma maps Int.
|
||||
const rawItems = await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
const [rawItems] = (await db.execute(sql`
|
||||
SELECT * FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
|
||||
ORDER BY order_number ASC, id ASC
|
||||
`;
|
||||
`)) as unknown as [Record<string, unknown>[], unknown[]];
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
@@ -164,13 +164,18 @@ export async function loadCatalogItemsData(
|
||||
}));
|
||||
|
||||
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
|
||||
prisma.catalogPages.findMany({
|
||||
orderBy: { caption: "asc" },
|
||||
select: { id: true, caption: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ interaction_type: string }>>`
|
||||
db
|
||||
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.caption)),
|
||||
db
|
||||
.execute(sql`
|
||||
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
|
||||
`,
|
||||
`)
|
||||
.then(
|
||||
(result) =>
|
||||
(result as unknown as [{ interaction_type: string }[], unknown[]])[0],
|
||||
),
|
||||
getHabboGamedataHotel(),
|
||||
]);
|
||||
|
||||
@@ -192,46 +197,46 @@ export async function loadCatalogItemsData(
|
||||
];
|
||||
|
||||
// Raw query to work around ENUM('0','1') columns returning numeric values.
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via Prisma.join.
|
||||
const baseItems: BaseItem[] =
|
||||
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
|
||||
const [baseItemRows] =
|
||||
baseItemIds.length > 0
|
||||
? (
|
||||
await prisma.$queryRaw<Array<Record<string, unknown>>>`
|
||||
? ((await db.execute(sql`
|
||||
SELECT id, sprite_id, public_name, item_name, type, width, length,
|
||||
stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
|
||||
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
|
||||
allow_inventory_stack, interaction_type, interaction_modes_count,
|
||||
vending_ids, customparams, effect_id_male, effect_id_female,
|
||||
clothing_on_walk
|
||||
FROM items_base WHERE id IN (${Prisma.join(baseItemIds)})
|
||||
`
|
||||
).map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}))
|
||||
: [];
|
||||
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
|
||||
`)) as unknown as [Record<string, unknown>[], unknown[]])
|
||||
: [[], []];
|
||||
|
||||
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
spriteId: Number(r.sprite_id),
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
allowWalk: Number(r.allow_walk ?? 0),
|
||||
allowGift: Number(r.allow_gift ?? 1),
|
||||
allowTrade: Number(r.allow_trade ?? 1),
|
||||
allowRecycle: Number(r.allow_recycle ?? 0),
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}));
|
||||
|
||||
const baseItemMap = Object.fromEntries(baseItems.map((b) => [b.id, b]));
|
||||
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.hoisted(() => vi.fn());
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
const update = vi.hoisted(() => vi.fn());
|
||||
const create = vi.hoisted(() => vi.fn());
|
||||
const queryRaw = vi.hoisted(() => vi.fn());
|
||||
const executeRaw = vi.hoisted(() => vi.fn());
|
||||
const catalogDelete = vi.hoisted(() => vi.fn());
|
||||
const catalogUpdateMany = vi.hoisted(() => vi.fn());
|
||||
const insertValues = vi.hoisted(() => vi.fn());
|
||||
const selectLimit = vi.hoisted(() => vi.fn());
|
||||
const updateSetData = vi.hoisted(() => vi.fn());
|
||||
const updateWhere = vi.hoisted(() => vi.fn());
|
||||
const deleteWhere = vi.hoisted(() => vi.fn());
|
||||
const executeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
catalogPages: {
|
||||
findUnique,
|
||||
findMany,
|
||||
update,
|
||||
create,
|
||||
delete: catalogDelete,
|
||||
updateMany: catalogUpdateMany,
|
||||
},
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: executeRaw,
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: () => ({ values: insertValues }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(selectLimit()),
|
||||
}),
|
||||
orderBy: () => ({
|
||||
where: () => ({
|
||||
limit: () => Promise.resolve(selectLimit()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (data: Record<string, unknown>) => {
|
||||
updateSetData(data);
|
||||
return {
|
||||
where: () => updateWhere(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => Promise.resolve(deleteWhere()),
|
||||
}),
|
||||
execute: executeMock,
|
||||
},
|
||||
CatalogPages: { id: "CatalogPages.id", parentId: "CatalogPages.parentId" },
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({ env: {} }));
|
||||
@@ -121,10 +135,10 @@ describe("buildNestedTree", () => {
|
||||
|
||||
describe("createPage", () => {
|
||||
it("creates page with default values", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({ parentId: 0, caption: "Test" });
|
||||
|
||||
const data = create.mock.calls[0][0].data;
|
||||
const data = insertValues.mock.calls[0][0];
|
||||
expect(data.caption).toBe("Test");
|
||||
expect(data.pageLayout).toBe("default_3x3");
|
||||
expect(data.visible).toBe("1");
|
||||
@@ -134,57 +148,51 @@ describe("createPage", () => {
|
||||
});
|
||||
|
||||
it("truncates captionSave to 25 chars", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
||||
|
||||
expect(create.mock.calls[0][0].data.captionSave).toBe("a".repeat(25));
|
||||
expect(insertValues.mock.calls[0][0].captionSave).toBe("a".repeat(25));
|
||||
});
|
||||
|
||||
it("converts boolean visible/enabled to string", async () => {
|
||||
create.mockResolvedValue({ id: 1 });
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
await createPage({
|
||||
parentId: 0,
|
||||
caption: "x",
|
||||
visible: false,
|
||||
enabled: false,
|
||||
});
|
||||
expect(create.mock.calls[0][0].data.visible).toBe("0");
|
||||
expect(create.mock.calls[0][0].data.enabled).toBe("0");
|
||||
expect(insertValues.mock.calls[0][0].visible).toBe("0");
|
||||
expect(insertValues.mock.calls[0][0].enabled).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("movePage", () => {
|
||||
it("throws on circular hierarchy", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 1 });
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
||||
});
|
||||
|
||||
it("allows valid move to root", async () => {
|
||||
update.mockResolvedValue({});
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
await movePage(1, 0);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { parentId: 0 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
||||
});
|
||||
|
||||
it("allows valid move to other parent", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 0 });
|
||||
update.mockResolvedValue({});
|
||||
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
await movePage(1, 2);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { parentId: 2 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
it("returns breadcrumb from root to page", async () => {
|
||||
findUnique
|
||||
.mockResolvedValueOnce({ id: 3, caption: "Grandchild", parentId: 2 })
|
||||
.mockResolvedValueOnce({ id: 2, caption: "Child", parentId: 1 })
|
||||
.mockResolvedValueOnce({ id: 1, caption: "Root", parentId: 0 });
|
||||
selectLimit
|
||||
.mockResolvedValueOnce([{ id: 3, caption: "Grandchild", parentId: 2 }])
|
||||
.mockResolvedValueOnce([{ id: 2, caption: "Child", parentId: 1 }])
|
||||
.mockResolvedValueOnce([{ id: 1, caption: "Root", parentId: 0 }]);
|
||||
|
||||
const ancestors = await getAncestors(3);
|
||||
expect(ancestors).toHaveLength(3);
|
||||
@@ -194,22 +202,19 @@ describe("getAncestors", () => {
|
||||
});
|
||||
|
||||
it("stops when page not found", async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
selectLimit.mockResolvedValue([]);
|
||||
expect(await getAncestors(999)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePage", () => {
|
||||
it("reparent mode moves children to parent", async () => {
|
||||
findUnique.mockResolvedValue({ parentId: 1 });
|
||||
catalogDelete.mockResolvedValue({});
|
||||
catalogUpdateMany.mockResolvedValue({ count: 2 });
|
||||
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
|
||||
const result = await deletePage(5, "reparent");
|
||||
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
||||
expect(catalogUpdateMany).toHaveBeenCalledWith({
|
||||
where: { parentId: 5 },
|
||||
data: { parentId: 1 },
|
||||
});
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
|
||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
||||
|
||||
@@ -23,25 +23,27 @@ export async function getCatalogItemCounts(
|
||||
|
||||
if (pageIds) {
|
||||
const idStrs = pageIds.map(String);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
||||
>`
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT page_id, COUNT(*) as cnt
|
||||
FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
||||
GROUP BY page_id
|
||||
`;
|
||||
`)) as unknown as [
|
||||
{ page_id: string | number; cnt: number | bigint }[],
|
||||
unknown[],
|
||||
];
|
||||
for (const r of rows) {
|
||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ page_id: string | number; cnt: number | bigint }>
|
||||
>`
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
|
||||
`;
|
||||
`)) as unknown as [
|
||||
{ page_id: string | number; cnt: number | bigint }[],
|
||||
unknown[],
|
||||
];
|
||||
for (const r of rows) {
|
||||
map.set(toInt(r.page_id), toInt(r.cnt));
|
||||
}
|
||||
@@ -55,9 +57,10 @@ export async function getCatalogItemCounts(
|
||||
* Get the full catalog tree as a flat list with depth info.
|
||||
*/
|
||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const allPages = await prisma.catalogPages.findMany({
|
||||
orderBy: { orderNum: "asc" },
|
||||
});
|
||||
const allPages = await db
|
||||
.select()
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.orderNum));
|
||||
const itemCountMap = await getCatalogItemCounts();
|
||||
|
||||
const childCountMap = new Map<number, number>();
|
||||
@@ -69,15 +72,23 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||
const depthCache = new Map<number, number>();
|
||||
|
||||
function computeDepth(pageId: number): number {
|
||||
function computeDepth(pageId: number, visiting = new Set<number>()): number {
|
||||
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
||||
const page = pageMap.get(pageId);
|
||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||
if (!page || parentId <= 0) {
|
||||
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
||||
if (visiting.has(pageId)) {
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(parentId) + 1;
|
||||
visiting.add(pageId);
|
||||
const page = pageMap.get(pageId);
|
||||
const parentId = page ? toInt(page.parentId, -1) : -1;
|
||||
if (!page || parentId <= 0) {
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, 0);
|
||||
return 0;
|
||||
}
|
||||
const d = computeDepth(parentId, visiting) + 1;
|
||||
visiting.delete(pageId);
|
||||
depthCache.set(pageId, d);
|
||||
return d;
|
||||
}
|
||||
@@ -154,24 +165,22 @@ export async function createPage(data: {
|
||||
: "0"
|
||||
: (data.enabled ?? "1");
|
||||
|
||||
return prisma.catalogPages.create({
|
||||
data: {
|
||||
caption: data.caption,
|
||||
parentId: data.parentId,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||
iconColor: data.iconColor ?? 0,
|
||||
iconImage: data.iconImage ?? 0,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum: data.orderNum ?? 0,
|
||||
visible,
|
||||
enabled,
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
return db.insert(CatalogPages).values({
|
||||
caption: data.caption,
|
||||
parentId: data.parentId,
|
||||
pageLayout: data.pageLayout ?? "default_3x3",
|
||||
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||
iconColor: data.iconColor ?? 0,
|
||||
iconImage: data.iconImage ?? 0,
|
||||
minRank: data.minRank ?? 1,
|
||||
orderNum: data.orderNum ?? 0,
|
||||
visible,
|
||||
enabled,
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,19 +198,20 @@ export async function movePage(
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const parent = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: pageId },
|
||||
data: { parentId: newParentId },
|
||||
});
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPages.id, pageId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,10 +221,10 @@ export async function movePage(
|
||||
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const idStrs = pageIds.map(String);
|
||||
await prisma.$executeRaw`
|
||||
await db.execute(sql`
|
||||
DELETE FROM catalog_items
|
||||
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
|
||||
`;
|
||||
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,22 +234,23 @@ export async function deletePage(
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
): Promise<{ deletedPages: number; movedChildren: number }> {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, pageId))
|
||||
.limit(1);
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
|
||||
if (mode === "reparent") {
|
||||
const result = await prisma.catalogPages.updateMany({
|
||||
where: { parentId: pageId },
|
||||
data: { parentId: page.parentId },
|
||||
});
|
||||
const [result] = await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPages.parentId, pageId));
|
||||
|
||||
await deleteCatalogItemsByPageIds([pageId]);
|
||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
||||
|
||||
return { deletedPages: 1, movedChildren: result.count };
|
||||
return { deletedPages: 1, movedChildren: result.affectedRows };
|
||||
}
|
||||
|
||||
const deleted = await cascadeDelete(pageId);
|
||||
@@ -251,10 +262,10 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
||||
const queue: number[] = [pageId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: { in: queue } },
|
||||
select: { id: true },
|
||||
});
|
||||
const children = await db
|
||||
.select({ id: CatalogPages.id })
|
||||
.from(CatalogPages)
|
||||
.where(inArray(CatalogPages.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
@@ -264,7 +275,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
||||
|
||||
await deleteCatalogItemsByPageIds(toDelete);
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
||||
}
|
||||
|
||||
return toDelete.length;
|
||||
@@ -279,10 +290,15 @@ export async function getAncestors(
|
||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||
let currentId = pageId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: currentId },
|
||||
select: { id: true, caption: true, parentId: true },
|
||||
});
|
||||
const [page] = await db
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
caption: CatalogPages.caption,
|
||||
parentId: CatalogPages.parentId,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, currentId))
|
||||
.limit(1);
|
||||
if (!page) break;
|
||||
ancestors.unshift({ id: page.id, caption: page.caption });
|
||||
if (page.parentId <= 0) break;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import type { Db } from "@/lib/db";
|
||||
|
||||
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
|
||||
@@ -41,11 +43,18 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
|
||||
permissionMaxValues: Record<string, number>;
|
||||
}
|
||||
|
||||
type RawDb = {
|
||||
$queryRaw: <T = unknown>(query: Prisma.Sql) => Promise<T>;
|
||||
$executeRaw: (query: Prisma.Sql) => Promise<number>;
|
||||
$transaction: <T>(queries: Promise<T>[]) => Promise<T[]>;
|
||||
};
|
||||
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */
|
||||
type RawDb = Pick<Db, "execute" | "transaction">;
|
||||
|
||||
async function rawRows<T>(db: RawDb, query: SQL | string): Promise<T> {
|
||||
const [rows] = await db.execute(query);
|
||||
return rows as T;
|
||||
}
|
||||
|
||||
async function rawExec(db: RawDb, query: SQL | string): Promise<number> {
|
||||
const [result] = await db.execute(query);
|
||||
return (result as ResultSetHeader).affectedRows;
|
||||
}
|
||||
|
||||
export function getRankPermissionColumn(rankId: number) {
|
||||
if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) {
|
||||
@@ -79,8 +88,9 @@ export function splitRankUpdateFields(fields: Record<string, string | number>) {
|
||||
export async function fetchEmulatorRankSummaries(
|
||||
db: RawDb,
|
||||
): Promise<EmulatorRankSummary[]> {
|
||||
const rows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
const rows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
|
||||
FROM permission_ranks
|
||||
ORDER BY id ASC
|
||||
@@ -102,8 +112,9 @@ export async function fetchEmulatorRankForEdit(
|
||||
db: RawDb,
|
||||
rankId: number,
|
||||
): Promise<EmulatorRankForEdit | null> {
|
||||
const rankRows = await db.$queryRaw<Record<string, unknown>[]>(
|
||||
Prisma.sql`
|
||||
const rankRows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||
auto_gotw_amount, auto_points_amount
|
||||
@@ -114,15 +125,16 @@ export async function fetchEmulatorRankForEdit(
|
||||
if (rankRows.length === 0) return null;
|
||||
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissionRows = await db.$queryRaw<
|
||||
const permissionRows = await rawRows<
|
||||
{
|
||||
permission_key: string;
|
||||
max_value: number;
|
||||
value: number | string | null;
|
||||
}[]
|
||||
>(
|
||||
Prisma.sql`
|
||||
SELECT permission_key, max_value, ${Prisma.raw(quoteIdentifier(rankColumn))} AS value
|
||||
db,
|
||||
sql`
|
||||
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
|
||||
FROM permission_definitions
|
||||
ORDER BY permission_key ASC
|
||||
`,
|
||||
@@ -159,40 +171,41 @@ export async function createEmulatorRank(
|
||||
db: RawDb,
|
||||
data: { rank_name: string; level: number },
|
||||
): Promise<number> {
|
||||
const rows = await db.$queryRaw<{ next_id: number }[]>(
|
||||
Prisma.sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||
const rows = await rawRows<{ next_id: number }[]>(
|
||||
db,
|
||||
sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
|
||||
);
|
||||
const id = Number(rows[0]?.next_id ?? 1);
|
||||
await ensurePermissionRankColumn(db, id);
|
||||
await resetPermissionRankColumn(db, id);
|
||||
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await db.transaction(async (tx) => {
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
INSERT INTO permission_ranks (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
`,
|
||||
),
|
||||
db.$executeRaw(
|
||||
Prisma.sql`
|
||||
);
|
||||
await rawExec(
|
||||
tx,
|
||||
sql`
|
||||
INSERT INTO permissions (id, rank_name, level)
|
||||
VALUES (${id}, ${data.rank_name}, ${data.level})
|
||||
ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level)
|
||||
`,
|
||||
),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function deleteEmulatorRank(db: RawDb, rankId: number) {
|
||||
await resetPermissionRankColumn(db, rankId);
|
||||
await db.$transaction([
|
||||
db.$executeRaw(
|
||||
Prisma.sql`DELETE FROM permission_ranks WHERE id = ${rankId}`,
|
||||
),
|
||||
db.$executeRaw(Prisma.sql`DELETE FROM permissions WHERE id = ${rankId}`),
|
||||
]);
|
||||
await db.transaction(async (tx) => {
|
||||
await rawExec(tx, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`);
|
||||
await rawExec(tx, sql`DELETE FROM permissions WHERE id = ${rankId}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEmulatorRank(
|
||||
@@ -220,13 +233,13 @@ async function updateTableFields(
|
||||
fields: Record<string, string | number>,
|
||||
) {
|
||||
const assignments = Object.entries(fields).map(
|
||||
([key, value]) =>
|
||||
Prisma.sql`${Prisma.raw(quoteIdentifier(key))} = ${value}`,
|
||||
([key, value]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`,
|
||||
);
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
UPDATE ${Prisma.raw(quoteIdentifier(table))}
|
||||
SET ${Prisma.join(assignments, ", ")}
|
||||
return rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE ${sql.raw(quoteIdentifier(table))}
|
||||
SET ${sql.join(assignments, sql`, `)}
|
||||
WHERE id = ${rankId}
|
||||
`,
|
||||
);
|
||||
@@ -240,19 +253,20 @@ async function updatePermissionDefinitionValues(
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const entries = Object.entries(permissionFields);
|
||||
const cases = entries.map(
|
||||
([key, value]) => Prisma.sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
||||
([key, value]) => sql`WHEN ${key} THEN LEAST(${value}, max_value)`,
|
||||
);
|
||||
const keys = entries.map(([key]) => key);
|
||||
|
||||
return db.$executeRaw(
|
||||
Prisma.sql`
|
||||
return rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE permission_definitions
|
||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} =
|
||||
SET ${sql.raw(quoteIdentifier(rankColumn))} =
|
||||
CASE permission_key
|
||||
${Prisma.join(cases, " ")}
|
||||
ELSE ${Prisma.raw(quoteIdentifier(rankColumn))}
|
||||
${sql.join(cases, sql` `)}
|
||||
ELSE ${sql.raw(quoteIdentifier(rankColumn))}
|
||||
END
|
||||
WHERE permission_key IN (${Prisma.join(keys)})
|
||||
WHERE permission_key IN (${sql.join(keys, sql`, `)})
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -272,8 +286,9 @@ async function updateLegacyPermissionFields(
|
||||
|
||||
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const exists = await db.$queryRaw<{ c: bigint | number }[]>(
|
||||
Prisma.sql`
|
||||
const exists = await rawRows<{ c: number }[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT COUNT(*) AS c
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
@@ -283,27 +298,30 @@ async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
|
||||
);
|
||||
if (Number(exists[0]?.c ?? 0) > 0) return;
|
||||
|
||||
await db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
ALTER TABLE permission_definitions
|
||||
ADD COLUMN ${Prisma.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
ADD COLUMN ${sql.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPermissionRankColumn(db: RawDb, rankId: number) {
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
await db.$executeRaw(
|
||||
Prisma.sql`
|
||||
await rawExec(
|
||||
db,
|
||||
sql`
|
||||
UPDATE permission_definitions
|
||||
SET ${Prisma.raw(quoteIdentifier(rankColumn))} = 0
|
||||
SET ${sql.raw(quoteIdentifier(rankColumn))} = 0
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getTableColumns(db: RawDb, table: string) {
|
||||
const rows = await db.$queryRaw<{ column_name: string }[]>(
|
||||
Prisma.sql`
|
||||
const rows = await rawRows<{ column_name: string }[]>(
|
||||
db,
|
||||
sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
|
||||
Reference in new issue
Block a user