refactor(schema): invert relationship between items and recipes
Move item_id to recipes table and drop recipe_id from items Update backend queries and admin UI to reflect the new domain model
This commit is contained in:
@@ -48,23 +48,11 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
|
||||
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||
PRIMARY KEY (recipe_id, acquisition_method_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
category_id integer NOT NULL REFERENCES item_categories(id),
|
||||
usage_id integer REFERENCES item_usages(id),
|
||||
recipe_id integer REFERENCES recipes(id),
|
||||
dyeable boolean NOT NULL DEFAULT false,
|
||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||
pattern_editable boolean NOT NULL DEFAULT false
|
||||
@@ -72,6 +60,49 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
|
||||
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
item_id integer NOT NULL UNIQUE REFERENCES items(id)
|
||||
);
|
||||
|
||||
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'items'
|
||||
AND column_name = 'recipe_id'
|
||||
) THEN
|
||||
EXECUTE '
|
||||
UPDATE recipes r
|
||||
SET item_id = linked.item_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (recipe_id) recipe_id, id AS item_id
|
||||
FROM items
|
||||
WHERE recipe_id IS NOT NULL
|
||||
ORDER BY recipe_id, id
|
||||
) linked
|
||||
WHERE r.id = linked.recipe_id
|
||||
AND r.item_id IS NULL
|
||||
';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DELETE FROM recipes WHERE item_id IS NULL;
|
||||
|
||||
ALTER TABLE recipes ALTER COLUMN item_id SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS recipes_item_id_key ON recipes(item_id);
|
||||
ALTER TABLE recipes DROP COLUMN IF EXISTS name;
|
||||
ALTER TABLE items DROP COLUMN IF EXISTS recipe_id;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
|
||||
recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
|
||||
PRIMARY KEY (recipe_id, acquisition_method_id)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS item_item_tags;
|
||||
DROP TABLE IF EXISTS item_tags;
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ type ItemPayload = {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
usageId: number | null;
|
||||
recipeId: number | null;
|
||||
dyeable: boolean;
|
||||
dualDyeable: boolean;
|
||||
patternEditable: boolean;
|
||||
@@ -50,7 +49,7 @@ type ItemPayload = {
|
||||
};
|
||||
|
||||
type RecipePayload = {
|
||||
name: string;
|
||||
itemId: number;
|
||||
acquisitionMethodIds: number[];
|
||||
materials: IdQuantity[];
|
||||
};
|
||||
@@ -649,7 +648,7 @@ export async function getItem(id: number) {
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
result_item.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||
FROM recipe_acquisition_methods ram
|
||||
@@ -661,10 +660,11 @@ export async function getItem(id: number) {
|
||||
FROM recipe_materials rm
|
||||
JOIN items mi ON mi.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
FROM items i
|
||||
JOIN recipes r ON r.id = i.recipe_id
|
||||
WHERE i.id = $1
|
||||
), '[]'::json) AS materials,
|
||||
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
||||
FROM recipes r
|
||||
JOIN items result_item ON result_item.id = r.item_id
|
||||
WHERE r.item_id = $1
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -684,9 +684,6 @@ export async function getItem(id: number) {
|
||||
}
|
||||
|
||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined
|
||||
? null
|
||||
: requirePositiveInteger(payload.recipeId, '请选择材料单');
|
||||
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
||||
? null
|
||||
: requirePositiveInteger(payload.usageId, '请选择用途');
|
||||
@@ -695,7 +692,6 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||
name: cleanName(payload.name, '请输入物品名字'),
|
||||
categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'),
|
||||
usageId,
|
||||
recipeId,
|
||||
dyeable: Boolean(payload.dyeable),
|
||||
dualDyeable: Boolean(payload.dualDyeable),
|
||||
patternEditable: Boolean(payload.patternEditable),
|
||||
@@ -729,15 +725,14 @@ export async function createItem(payload: Record<string, unknown>) {
|
||||
const id = await withTransaction(async (client) => {
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO items (name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO items (name, category_id, usage_id, dyeable, dual_dyeable, pattern_editable)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
cleanPayload.name,
|
||||
cleanPayload.categoryId,
|
||||
cleanPayload.usageId,
|
||||
cleanPayload.recipeId,
|
||||
cleanPayload.dyeable,
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable
|
||||
@@ -760,17 +755,15 @@ export async function updateItem(id: number, payload: Record<string, unknown>) {
|
||||
SET name = $1,
|
||||
category_id = $2,
|
||||
usage_id = $3,
|
||||
recipe_id = $4,
|
||||
dyeable = $5,
|
||||
dual_dyeable = $6,
|
||||
pattern_editable = $7
|
||||
WHERE id = $8
|
||||
dyeable = $4,
|
||||
dual_dyeable = $5,
|
||||
pattern_editable = $6
|
||||
WHERE id = $7
|
||||
`,
|
||||
[
|
||||
cleanPayload.name,
|
||||
cleanPayload.categoryId,
|
||||
cleanPayload.usageId,
|
||||
cleanPayload.recipeId,
|
||||
cleanPayload.dyeable,
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable,
|
||||
@@ -795,7 +788,7 @@ export async function listRecipes() {
|
||||
return query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
result_item.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name)
|
||||
FROM recipe_materials rm
|
||||
@@ -803,7 +796,8 @@ export async function listRecipes() {
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
FROM recipes r
|
||||
ORDER BY r.name
|
||||
JOIN items result_item ON result_item.id = r.item_id
|
||||
ORDER BY result_item.name
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -812,7 +806,7 @@ export async function getRecipe(id: number) {
|
||||
`
|
||||
SELECT
|
||||
r.id,
|
||||
r.name,
|
||||
result_item.name,
|
||||
COALESCE((
|
||||
SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name)
|
||||
FROM recipe_acquisition_methods ram
|
||||
@@ -824,8 +818,10 @@ export async function getRecipe(id: number) {
|
||||
FROM recipe_materials rm
|
||||
JOIN items i ON i.id = rm.item_id
|
||||
WHERE rm.recipe_id = r.id
|
||||
), '[]'::json) AS materials
|
||||
), '[]'::json) AS materials,
|
||||
json_build_object('id', result_item.id, 'name', result_item.name) AS item
|
||||
FROM recipes r
|
||||
JOIN items result_item ON result_item.id = r.item_id
|
||||
WHERE r.id = $1
|
||||
`,
|
||||
[id]
|
||||
@@ -834,7 +830,7 @@ export async function getRecipe(id: number) {
|
||||
|
||||
function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
|
||||
return {
|
||||
name: cleanName(payload.name, '请输入材料单名字'),
|
||||
itemId: requirePositiveInteger(payload.itemId, '请选择物品'),
|
||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||
materials: cleanQuantities(payload.materials)
|
||||
};
|
||||
@@ -860,12 +856,20 @@ async function replaceRecipeRelations(client: DbClient, recipeId: number, payloa
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureItemExists(client: DbClient, itemId: number): Promise<void> {
|
||||
const result = await client.query('SELECT 1 FROM items WHERE id = $1', [itemId]);
|
||||
if (result.rowCount === 0) {
|
||||
throw validationError('请选择物品');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRecipe(payload: Record<string, unknown>) {
|
||||
const cleanPayload = cleanRecipePayload(payload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const result = await client.query<{ id: number }>('INSERT INTO recipes (name) VALUES ($1) RETURNING id', [
|
||||
cleanPayload.name
|
||||
await ensureItemExists(client, cleanPayload.itemId);
|
||||
const result = await client.query<{ id: number }>('INSERT INTO recipes (item_id) VALUES ($1) RETURNING id', [
|
||||
cleanPayload.itemId
|
||||
]);
|
||||
const recipeId = result.rows[0].id;
|
||||
await replaceRecipeRelations(client, recipeId, cleanPayload);
|
||||
@@ -878,7 +882,8 @@ export async function updateRecipe(id: number, payload: Record<string, unknown>)
|
||||
const cleanPayload = cleanRecipePayload(payload);
|
||||
|
||||
const updated = await withTransaction(async (client) => {
|
||||
const result = await client.query('UPDATE recipes SET name = $1 WHERE id = $2', [cleanPayload.name, id]);
|
||||
await ensureItemExists(client, cleanPayload.itemId);
|
||||
const result = await client.query('UPDATE recipes SET item_id = $1 WHERE id = $2', [cleanPayload.itemId, id]);
|
||||
if (result.rowCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user