feat: add fuzzy search

This commit is contained in:
Henry
2025-10-01 23:13:22 +01:00
parent bd691017ad
commit f8475b77f1
6 changed files with 2818 additions and 6 deletions

View File

@@ -22,6 +22,8 @@ type SearchResult =
title: string; title: string;
description: string | null; description: string | null;
slug: string; slug: string;
updatedAt: Date | null;
createdAt: Date;
type: "board"; type: "board";
} }
| { | {
@@ -31,6 +33,8 @@ type SearchResult =
boardPublicId: string; boardPublicId: string;
boardName: string; boardName: string;
listName: string; listName: string;
updatedAt: Date | null;
createdAt: Date;
type: "card"; type: "card";
}; };

View File

@@ -425,6 +425,7 @@ export const workspaceRouter = createTRPCRouter({
z.object({ z.object({
workspacePublicId: z.string().min(12), workspacePublicId: z.string().min(12),
query: z.string().min(1).max(100), query: z.string().min(1).max(100),
limit: z.number().min(1).max(50).optional().default(20),
}), }),
) )
.output( .output(
@@ -435,6 +436,8 @@ export const workspaceRouter = createTRPCRouter({
title: z.string(), title: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
slug: z.string(), slug: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("board"), type: z.literal("board"),
}), }),
z.object({ z.object({
@@ -444,6 +447,8 @@ export const workspaceRouter = createTRPCRouter({
boardPublicId: z.string(), boardPublicId: z.string(),
boardName: z.string(), boardName: z.string(),
listName: z.string(), listName: z.string(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"), type: z.literal("card"),
}), }),
]), ]),
@@ -475,6 +480,7 @@ export const workspaceRouter = createTRPCRouter({
ctx.db, ctx.db,
workspace.id, workspace.id,
input.query, input.query,
input.limit,
); );
return result; return result;

View File

@@ -0,0 +1,4 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm; --> statement-breakpoint
CREATE INDEX IF NOT EXISTS boards_name_trgm_idx ON board USING gin (name gin_trgm_ops);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS cards_title_trgm_idx ON card USING gin (title gin_trgm_ops);

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1758662398166, "when": 1758662398166,
"tag": "20250923211958_AddWorkspaceInviteLinks", "tag": "20250923211958_AddWorkspaceInviteLinks",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1759356096392,
"tag": "20251001220136_AddFuzzySearchSupport",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull } from "drizzle-orm"; import { and, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import { import {
@@ -285,6 +285,7 @@ export const searchBoardsAndCards = async (
db: dbClient, db: dbClient,
workspaceId: number, workspaceId: number,
query: string, query: string,
limit = 20,
) => { ) => {
const searchQuery = `%${query}%`; const searchQuery = `%${query}%`;
@@ -295,15 +296,27 @@ export const searchBoardsAndCards = async (
title: boards.name, title: boards.name,
description: boards.description, description: boards.description,
slug: boards.slug, slug: boards.slug,
updatedAt: boards.updatedAt,
createdAt: boards.createdAt,
}) })
.from(boards) .from(boards)
.where( .where(
and( and(
eq(boards.workspaceId, workspaceId), eq(boards.workspaceId, workspaceId),
ilike(boards.name, searchQuery), // Combine exact and fuzzy matching
or(
ilike(boards.name, `%${query}%`), // Exact substring match
sql`similarity(${boards.name}, ${query}) > 0.2`, // Fuzzy match
),
isNull(boards.deletedAt), isNull(boards.deletedAt),
), ),
); )
.orderBy(
sql`CASE WHEN ${boards.name} ILIKE ${`%${query}%`} THEN 1 ELSE 0 END DESC`,
sql`similarity(${boards.name}, ${query}) DESC`,
desc(boards.updatedAt),
)
.limit(Math.ceil(limit * 0.4));
// Search for cards // Search for cards
const cardResults = await db const cardResults = await db
@@ -314,6 +327,8 @@ export const searchBoardsAndCards = async (
boardPublicId: boards.publicId, boardPublicId: boards.publicId,
boardName: boards.name, boardName: boards.name,
listName: lists.name, listName: lists.name,
updatedAt: cards.updatedAt,
createdAt: cards.createdAt,
}) })
.from(cards) .from(cards)
.innerJoin(lists, eq(cards.listId, lists.id)) .innerJoin(lists, eq(cards.listId, lists.id))
@@ -321,12 +336,21 @@ export const searchBoardsAndCards = async (
.where( .where(
and( and(
eq(boards.workspaceId, workspaceId), eq(boards.workspaceId, workspaceId),
ilike(cards.title, searchQuery), or(
ilike(cards.title, searchQuery),
sql`similarity(${cards.title}, ${query}) > 0.2`,
),
isNull(cards.deletedAt), isNull(cards.deletedAt),
isNull(lists.deletedAt), isNull(lists.deletedAt),
isNull(boards.deletedAt), isNull(boards.deletedAt),
), ),
); )
.orderBy(
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
)
.limit(Math.floor(limit * 0.6));
// Combine results // Combine results
const allResults = [ const allResults = [
@@ -334,5 +358,6 @@ export const searchBoardsAndCards = async (
...cardResults.map((card) => ({ ...card, type: "card" as const })), ...cardResults.map((card) => ({ ...card, type: "card" as const })),
]; ];
return allResults; // Ensure we don't exceed the total limit
return allResults.slice(0, limit);
}; };