feat(19-03): add tag filtering to global item search and migrate owner count
- searchGlobalItems now accepts tagNames param with AND intersection logic - Owner count uses items.globalItemId instead of removed itemGlobalLinks - Removed linkItemToGlobal and unlinkItemFromGlobal functions - Route handlers now async with tags query param support - Rewrote tests to async PGlite pattern, added tag filtering tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,21 +9,26 @@ type Env = { Variables: { db?: any } };
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
app.get("/", async (c) => {
|
||||
const db = c.get("db");
|
||||
const q = c.req.query("q");
|
||||
const items = searchGlobalItems(db, q || undefined);
|
||||
const tagsParam = c.req.query("tags");
|
||||
const tagNames = tagsParam
|
||||
? tagsParam
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const items = await searchGlobalItems(db, q || undefined, tagNames);
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
app.get("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = parseId(c.req.param("id"));
|
||||
if (!id) return c.json({ error: "Invalid global item ID" }, 400);
|
||||
|
||||
const item = getGlobalItemWithOwnerCount(db, id);
|
||||
const item = await getGlobalItemWithOwnerCount(db, id);
|
||||
if (!item) return c.json({ error: "Global item not found" }, 404);
|
||||
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import { count, eq, like, or, sql } from "drizzle-orm";
|
||||
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { globalItems, itemGlobalLinks } from "../../db/schema.ts";
|
||||
import { globalItemTags, globalItems, items, tags } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
/**
|
||||
* Search global items by brand or model. LIKE is case-insensitive for ASCII.
|
||||
* Search global items by brand or model and/or tag names.
|
||||
* Text search uses ILIKE for case-insensitive matching (PostgreSQL).
|
||||
* Tag filtering uses AND logic -- items must have ALL specified tags.
|
||||
* Escapes % and _ wildcard characters in user input.
|
||||
*/
|
||||
export async function searchGlobalItems(db: Db = prodDb, query?: string) {
|
||||
if (!query) {
|
||||
return db.select().from(globalItems);
|
||||
export async function searchGlobalItems(
|
||||
db: Db = prodDb,
|
||||
query?: string,
|
||||
tagNames?: string[],
|
||||
) {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query) {
|
||||
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
const pattern = `%${escaped}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(globalItems.brand, pattern),
|
||||
ilike(globalItems.model, pattern),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
// Escape SQL LIKE wildcards
|
||||
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
const pattern = `%${escaped}%`;
|
||||
if (tagNames && tagNames.length > 0) {
|
||||
conditions.push(
|
||||
sql`${globalItems.id} IN (
|
||||
SELECT ${globalItemTags.globalItemId}
|
||||
FROM ${globalItemTags}
|
||||
JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
|
||||
WHERE ${tags.name} IN (${sql.join(
|
||||
tagNames.map((t) => sql`${t}`),
|
||||
sql`, `,
|
||||
)})
|
||||
GROUP BY ${globalItemTags.globalItemId}
|
||||
HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return db.select().from(globalItems);
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(globalItems)
|
||||
.where(
|
||||
or(like(globalItems.brand, pattern), like(globalItems.model, pattern)),
|
||||
);
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single global item by ID with the count of user items linked to it.
|
||||
* Get a single global item by ID with the count of user items referencing it
|
||||
* via items.globalItemId.
|
||||
*/
|
||||
export async function getGlobalItemWithOwnerCount(
|
||||
db: Db = prodDb,
|
||||
@@ -41,35 +72,8 @@ export async function getGlobalItemWithOwnerCount(
|
||||
|
||||
const [result] = await db
|
||||
.select({ ownerCount: count() })
|
||||
.from(itemGlobalLinks)
|
||||
.where(eq(itemGlobalLinks.globalItemId, id));
|
||||
.from(items)
|
||||
.where(eq(items.globalItemId, id));
|
||||
|
||||
return { ...item, ownerCount: result?.ownerCount ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a user's item to a global item. Throws on duplicate (unique constraint on itemId).
|
||||
*/
|
||||
export async function linkItemToGlobal(
|
||||
db: Db = prodDb,
|
||||
itemId: number,
|
||||
globalItemId: number,
|
||||
) {
|
||||
const [row] = await db
|
||||
.insert(itemGlobalLinks)
|
||||
.values({ itemId, globalItemId })
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the link between a user's item and any global item.
|
||||
*/
|
||||
export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) {
|
||||
const result = await db
|
||||
.delete(itemGlobalLinks)
|
||||
.where(eq(itemGlobalLinks.itemId, itemId))
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user