Moving the arttags table to tags table part 2

This commit is contained in:
2026-02-02 13:48:49 +01:00
parent 7605ccb0aa
commit ed81662ae5
13 changed files with 195 additions and 245 deletions

View File

@ -13,7 +13,6 @@ export async function deleteArtwork(artworkId: string) {
colors: true,
metadata: true,
tags: true,
tagsV2: true,
categories: true,
},
});
@ -75,7 +74,6 @@ export async function deleteArtwork(artworkId: string) {
where: { id: artworkId },
data: {
tags: { set: [] },
tagsV2: { set: [] },
categories: { set: [] },
},
});

View File

@ -15,7 +15,7 @@ export async function getSingleArtwork(id: string) {
categories: true,
colors: { include: { color: true } },
// sortContexts: true,
tagsV2: true,
tags: true,
variants: true,
timelapse: true
}

View File

@ -22,7 +22,7 @@ function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) {
// relation counts: Prisma supports ordering by _count
albumsCount: (desc) => ({ albums: { _count: desc ? "desc" : "asc" } }),
categoriesCount: (desc) => ({ categories: { _count: desc ? "desc" : "asc" } }),
tagsCount: (desc) => ({ tagsV2: { _count: desc ? "desc" : "asc" } }),
tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }),
};
const orderBy = sorting
@ -89,7 +89,7 @@ export async function getArtworksTablePage(input: unknown) {
gallery: { select: { id: true, name: true } },
albums: { select: { id: true, name: true } },
categories: { select: { id: true, name: true } },
_count: { select: { albums: true, categories: true, tagsV2: true } },
_count: { select: { albums: true, categories: true, tags: true } },
},
}),
]);
@ -109,7 +109,7 @@ export async function getArtworksTablePage(input: unknown) {
categories: a.categories,
albumsCount: a._count.albums,
categoriesCount: a._count.categories,
tagsCount: a._count.tagsV2,
tagsCount: a._count.tags,
}));
const out = { rows, total, pageIndex, pageSize };

View File

@ -47,7 +47,7 @@ export async function updateArtwork(
const tagsRelation =
tagIds || tagsToCreate.length
? {
tagsV2: {
tags: {
set: [], // replace entire relation
connect: (tagIds ?? []).map((tagId) => ({ id: tagId })),
connectOrCreate: tagsToCreate.map((tName) => ({

View File

@ -1,102 +1,5 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function migrateArtTags() {
const artTags = await prisma.artTag.findMany({
include: {
aliases: true,
categories: true,
artworks: { select: { id: true } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const idMap = new Map<string, string>();
await prisma.$transaction(async (tx) => {
for (const artTag of artTags) {
const tag = await tx.tag.upsert({
where: { slug: artTag.slug },
update: {
name: artTag.name,
description: artTag.description,
isVisible: true,
},
create: {
name: artTag.name,
slug: artTag.slug,
description: artTag.description,
isVisible: true,
},
});
idMap.set(artTag.id, tag.id);
}
const aliasRows = artTags.flatMap((artTag) => {
const tagId = idMap.get(artTag.id);
if (!tagId) return [];
return artTag.aliases.map((a) => ({
tagId,
alias: a.alias,
}));
});
if (aliasRows.length > 0) {
await tx.tagAlias.createMany({
data: aliasRows,
skipDuplicates: true,
});
}
const categoryRows = artTags.flatMap((artTag) => {
const tagId = idMap.get(artTag.id);
if (!tagId) return [];
const parentTagId = artTag.parentId
? idMap.get(artTag.parentId) ?? null
: null;
return artTag.categories.map((category) => ({
tagId,
categoryId: category.id,
isParent: artTag.isParent,
showOnAnimalPage: artTag.showOnAnimalPage,
parentTagId,
}));
});
if (categoryRows.length > 0) {
await tx.tagCategory.createMany({
data: categoryRows,
skipDuplicates: true,
});
}
});
// Connect artwork relations outside the transaction to avoid timeouts.
for (const artTag of artTags) {
const tagId = idMap.get(artTag.id);
if (!tagId) continue;
for (const artwork of artTag.artworks) {
await prisma.artwork.update({
where: { id: artwork.id },
data: {
tagsV2: {
connect: { id: tagId },
},
},
});
}
}
const summary = {
tags: artTags.length,
aliases: artTags.reduce((sum, t) => sum + t.aliases.length, 0),
categoryLinks: artTags.reduce((sum, t) => sum + t.categories.length, 0),
};
revalidatePath("/tags");
return summary;
throw new Error("Migration disabled: ArtTag models removed.");
}

View File

@ -0,0 +1,75 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
type JoinMigrationResult = {
ok: boolean;
copied: number;
oldExists: boolean;
newExists: boolean;
droppedOld: boolean;
message?: string;
};
export async function migrateArtworkTagJoin(
opts: { dropOld?: boolean } = {},
): Promise<JoinMigrationResult> {
const dropOld = Boolean(opts.dropOld);
const [oldRow, newRow] = await Promise.all([
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTagsV2')::text as name;
`,
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTags')::text as name;
`,
]);
const oldExists = Boolean(oldRow?.[0]?.name);
const newExists = Boolean(newRow?.[0]?.name);
if (!newExists) {
return {
ok: false,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "New join table _ArtworkTags does not exist. Run the migration first.",
};
}
if (!oldExists) {
return {
ok: true,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "Old join table _ArtworkTagsV2 not found. Nothing to copy.",
};
}
const copied = await prisma.$executeRawUnsafe(`
INSERT INTO "_ArtworkTags" ("A","B")
SELECT "A","B" FROM "_ArtworkTagsV2"
ON CONFLICT ("A","B") DO NOTHING;
`);
let droppedOld = false;
if (dropOld) {
await prisma.$executeRawUnsafe(`DROP TABLE "_ArtworkTagsV2";`);
droppedOld = true;
}
revalidatePath("/tags");
return {
ok: true,
copied: Number(copied ?? 0),
oldExists,
newExists,
droppedOld,
};
}

View File

@ -1,9 +1,19 @@
import { migrateArtworkTagJoin } from "@/actions/tags/migrateArtworkTagJoin";
import TagTabs from "@/components/tags/TagTabs";
import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
import { migrateArtTags } from "@/actions/tags/migrateArtTags";
async function migrateArtworkTagJoinCopy() {
"use server";
await migrateArtworkTagJoin();
}
async function migrateArtworkTagJoinDropOld() {
"use server";
await migrateArtworkTagJoin({ dropOld: true });
}
export default async function ArtTagsPage() {
const items = await prisma.tag.findMany({
@ -52,9 +62,14 @@ export default async function ArtTagsPage() {
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<form action={migrateArtTags}>
<form action={migrateArtworkTagJoinCopy}>
<Button type="submit" variant="secondary" className="h-11">
Migrate old tags
Copy tag relations
</Button>
</form>
<form action={migrateArtworkTagJoinDropOld}>
<Button type="submit" variant="destructive" className="h-11">
Copy + drop old
</Button>
</form>
<Button asChild className="h-11 gap-2">

View File

@ -171,7 +171,7 @@ export default function ArtworkDetails({
v: (
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary">{(artwork.categories?.length ?? 0)} categories</Badge>
<Badge variant="secondary">{(artwork.tagsV2?.length ?? 0)} tags</Badge>
<Badge variant="secondary">{(artwork.tags?.length ?? 0)} tags</Badge>
<Badge variant="secondary">{(artwork.colors?.length ?? 0)} colors</Badge>
<Badge variant="secondary">{(artwork.variants?.length ?? 0)} variants</Badge>
</div>

View File

@ -39,7 +39,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
year: artwork.year || undefined,
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
tagIds: artwork.tagsV2?.map(tag => tag.id) ?? [],
tagIds: artwork.tags?.map(tag => tag.id) ?? [],
newCategoryNames: [],
newTagNames: []
}
@ -283,6 +283,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
.filter((t) => selectedTagIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
const fallbackSelectedOptions =
artwork.tags
?.filter((t) => selectedTagIds.includes(t.id))
.filter((t) => !selectedExistingOptions.some((o) => o.value === t.id))
.map((t) => ({ label: t.name, value: t.id })) ?? [];
// Selected "new" tags (so they remain visible)
const selectedNewOptions = newTagNames.map((name) => ({
label: `Create: ${name}`,
@ -302,7 +308,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
placeholder="Select or type to create tags"
hidePlaceholderWhenSelected
selectFirstItem
value={[...selectedExistingOptions, ...selectedNewOptions]}
value={[
...selectedExistingOptions,
...fallbackSelectedOptions,
...selectedNewOptions,
]}
creatable
createOption={(raw) => ({
value: `__new__:${raw}`,

View File

@ -142,15 +142,6 @@ function removePickedOption(groupOption: GroupOption, picked: Option[]) {
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
function normalizeInput(s: string) {
return s.trim().replace(/\s+/g, " ");
}
@ -238,7 +229,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
[selected],
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
const handleClickOutside = React.useCallback((event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
@ -248,7 +239,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
setOpen(false);
inputRef.current.blur();
}
};
}, []);
const handleUnselect = React.useCallback(
(option: Option) => {
@ -294,7 +285,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
}, [open, handleClickOutside]);
useEffect(() => {
if (value) {
@ -311,7 +302,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
}, [arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
@ -334,8 +325,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
}, [debouncedSearchTerm, groupBy, onSearchSync, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
@ -360,8 +350,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
}, [debouncedSearchTerm, groupBy, onSearch, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
@ -448,14 +437,10 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}, [commandProps?.filter]);
const orderedGroupEntries = React.useMemo(() => {
const entries = Object.entries(selectables);
@ -495,7 +480,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
<button
type="button"
className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
{
@ -508,6 +494,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
if (disabled) return;
inputRef?.current?.focus();
}}
disabled={disabled}
>
<div className="relative flex flex-wrap gap-1">
{selected.map((option) => {
@ -594,7 +581,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
<X />
</button>
</div>
</div>
</button>
<div className="relative">
{open && (
<CommandList
@ -626,7 +613,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return (
<CommandItem
key={option.value}
value={option.value}
value={`${option.label}::${option.value}`}
disabled={disabledItem}
onMouseDown={(e) => {
e.preventDefault();

View File

@ -26,7 +26,7 @@ export async function getArtworksPage(params: ArtworkListParams) {
albums: true,
categories: true,
colors: true,
tagsV2: true,
tags: true,
variants: true,
},
orderBy: [{ createdAt: "desc" }, { id: "asc" }],

View File

@ -8,7 +8,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
albums: true;
categories: true;
colors: true;
tagsV2: true;
tags: true;
variants: true;
timelapse: true;
};