feat: upgrade search to PostgreSQL full-text with GIN index
Replace Prisma contains-based search with raw SQL using ts_vector/ts_query for ranked results and headline snippets with <mark> highlighting. Falls back to ILIKE for partial matches. GIN index applied to production DB. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
118710999b
commit
f7fa7bc7a1
|
|
@ -14,54 +14,96 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use Prisma contains for search (works without GIN index)
|
||||
// Can upgrade to raw SQL full-text search once GIN index is in place
|
||||
const where: Record<string, unknown> = {
|
||||
OR: [
|
||||
{ title: { contains: q, mode: 'insensitive' } },
|
||||
{ contentPlain: { contains: q, mode: 'insensitive' } },
|
||||
{ content: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
// Build WHERE clauses for optional filters
|
||||
const filters: string[] = [];
|
||||
const params: (string | null)[] = [q]; // $1 = search query
|
||||
|
||||
if (type) where.type = type;
|
||||
if (notebookId) where.notebookId = notebookId;
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`n."type" = $${params.length}::"NoteType"`);
|
||||
}
|
||||
if (notebookId) {
|
||||
params.push(notebookId);
|
||||
filters.push(`n."notebookId" = $${params.length}`);
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
||||
|
||||
const results = notes.map((note) => {
|
||||
// Build snippet from contentPlain
|
||||
const plain = note.contentPlain || note.content || '';
|
||||
const idx = plain.toLowerCase().indexOf(q.toLowerCase());
|
||||
const start = Math.max(0, idx - 50);
|
||||
const end = Math.min(plain.length, idx + q.length + 100);
|
||||
const snippet = (start > 0 ? '...' : '') + plain.slice(start, end) + (end < plain.length ? '...' : '');
|
||||
// Full-text search using PostgreSQL ts_vector + ts_query
|
||||
// Falls back to ILIKE if the GIN index hasn't been created yet
|
||||
const results = await prisma.$queryRawUnsafe<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentPlain: string | null;
|
||||
type: string;
|
||||
notebookId: string | null;
|
||||
notebookTitle: string | null;
|
||||
isPinned: boolean;
|
||||
updatedAt: Date;
|
||||
rank: number;
|
||||
headline: string | null;
|
||||
}>>(
|
||||
`SELECT
|
||||
n.id,
|
||||
n.title,
|
||||
n.content,
|
||||
n."contentPlain",
|
||||
n.type::"text" as type,
|
||||
n."notebookId",
|
||||
nb.title as "notebookTitle",
|
||||
n."isPinned",
|
||||
n."updatedAt",
|
||||
ts_rank(
|
||||
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title),
|
||||
plainto_tsquery('english', $1)
|
||||
) as rank,
|
||||
ts_headline('english',
|
||||
COALESCE(n."contentPlain", n.content, ''),
|
||||
plainto_tsquery('english', $1),
|
||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
|
||||
) as headline
|
||||
FROM "Note" n
|
||||
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
||||
WHERE (
|
||||
to_tsvector('english', COALESCE(n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
|
||||
OR n.title ILIKE '%' || $1 || '%'
|
||||
OR n."contentPlain" ILIKE '%' || $1 || '%'
|
||||
)
|
||||
${whereClause}
|
||||
ORDER BY rank DESC, n."updatedAt" DESC
|
||||
LIMIT 50`,
|
||||
...params
|
||||
);
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
snippet,
|
||||
type: note.type,
|
||||
notebookId: note.notebookId,
|
||||
notebookTitle: note.notebook?.title || null,
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
tags: note.tags.map((nt) => ({
|
||||
id: nt.tag.id,
|
||||
name: nt.tag.name,
|
||||
color: nt.tag.color,
|
||||
})),
|
||||
};
|
||||
});
|
||||
// Fetch tags for matched notes
|
||||
const noteIds = results.map((r) => r.id);
|
||||
const noteTags = noteIds.length > 0
|
||||
? await prisma.noteTag.findMany({
|
||||
where: { noteId: { in: noteIds } },
|
||||
include: { tag: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
return NextResponse.json(results);
|
||||
const tagsByNoteId = new Map<string, Array<{ id: string; name: string; color: string | null }>>();
|
||||
for (const nt of noteTags) {
|
||||
const arr = tagsByNoteId.get(nt.noteId) || [];
|
||||
arr.push({ id: nt.tag.id, name: nt.tag.name, color: nt.tag.color });
|
||||
tagsByNoteId.set(nt.noteId, arr);
|
||||
}
|
||||
|
||||
const response = results.map((note) => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
snippet: note.headline || (note.contentPlain || note.content || '').slice(0, 150),
|
||||
type: note.type,
|
||||
notebookId: note.notebookId,
|
||||
notebookTitle: note.notebookTitle,
|
||||
updatedAt: new Date(note.updatedAt).toISOString(),
|
||||
tags: tagsByNoteId.get(note.id) || [],
|
||||
}));
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Search notes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to search notes' }, { status: 500 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue