feat: add fuzzy search
This commit is contained in:
@@ -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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
2766
packages/db/migrations/meta/20251001220136_snapshot.json
Normal file
2766
packages/db/migrations/meta/20251001220136_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user