feat: second half of search functionality

This commit is contained in:
zk
2026-03-28 13:15:03 +00:00
parent 1bb32efd8a
commit 6ed72052fc
5 changed files with 572 additions and 58 deletions
+20 -12
View File
@@ -1,14 +1,16 @@
import DB from "#services/pg/db";
import type {
ChartDocument,
ChartDocumentData,
Difficulties,
GPTString,
Versions,
} from "tachi-common";
import { V3ToGamePT } from "tachi-common";
import type { Game } from "tachi-db";
import DB from "#services/pg/db";
import { sql, type SqlBool } from "kysely";
import {
type ChartDocument,
type ChartDocumentData,
type Difficulties,
type GPTString,
V3ToGamePT,
type Versions,
} from "tachi-common";
/**
* Fetches all charts for a given PG game string (e.g. "iidx-sp") and song PG UUID,
* including their version tags from `chart_version`. Returns fully-formed
@@ -18,8 +20,9 @@ export async function GetChartsBySongPgId(
gamePt: Game,
songPgId: string,
songLegacyId: number,
opts?: { omit2dxtraCharts?: boolean },
): Promise<ChartDocument[]> {
const rows = await DB.selectFrom("chart")
let q = DB.selectFrom("chart")
.select([
"id",
"legacy_id",
@@ -31,8 +34,13 @@ export async function GetChartsBySongPgId(
"data",
])
.where("song_id", "=", songPgId)
.where("game", "=", gamePt)
.execute();
.where("game", "=", gamePt);
if (opts?.omit2dxtraCharts && String(gamePt).startsWith("iidx-")) {
q = q.where(sql<SqlBool>`(data->>'2dxtraSet') IS NULL`);
}
const rows = await q.execute();
if (rows.length === 0) {
return [];
@@ -1,20 +1,28 @@
import { SearchSpecificGameSongs } from "#lib/search/search";
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { SearchGlobalGameSongsAndCharts, SearchSpecificGameSongs } from "#lib/search/search";
import DB from "#services/pg/db";
import { importSeedsSubset } from "#services/pg/seeds";
import { resolveSeedsDir, seedsJsonAvailable } from "#test-utils/seed-paths";
import { FindChartsOnPopularity } from "#utils/queries/charts";
import { sql } from "kysely";
import { GamePTToV3 } from "tachi-common";
import { describe, expect, it } from "vitest";
import {
LoadSongChildrenForPgIds,
MAX_SONG_SEARCH_RESULTS_PER_GAME,
SearchSongsForGameFtsAndTrgm,
SHORT_QUERY_STRICT_MAX_LEN,
} from "./song-search";
function makeSongId(n: number): string {
return `S${n.toString(16).padStart(20, "0")}`;
}
function makeChartId(n: number): string {
return `C${n.toString(16).padStart(20, "0")}`;
}
async function countSongRows(): Promise<number> {
const { rows } = await sql<{ c: bigint }>`
SELECT count(*)::bigint AS c FROM song
@@ -134,7 +142,7 @@ describe("SearchSongsForGameFtsAndTrgm (synthetic rows)", () => {
expect(res.length).toBe(MAX_SONG_SEARCH_RESULTS_PER_GAME);
});
it("uses trgm / short-query path for very short queries", async () => {
it("finds a two-letter title via exact match (strict short query, not substring trgm)", async () => {
await DB.insertInto("song")
.values({
id: makeSongId(300),
@@ -153,6 +161,127 @@ describe("SearchSongsForGameFtsAndTrgm (synthetic rows)", () => {
});
});
describe("SearchSongsForGameFtsAndTrgm (very short titles / strict query)", () => {
it("does not flood results for a single-letter search: only exact title/artist (and terms)", async () => {
await DB.insertInto("song")
.values([
{
id: makeSongId(700),
legacy_id: 9_000_700,
game_group: "iidx",
title: "A",
artist: "Some Artist",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(701),
legacy_id: 9_000_701,
game_group: "iidx",
title: "About Something",
artist: "B",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(702),
legacy_id: 9_000_702,
game_group: "iidx",
title: "Many Letters",
artist: "Alpha",
fts_document: "",
data: JSON.stringify({}),
},
])
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "A", 20);
const legacyIds = res.map((r) => r.legacy_id);
expect(legacyIds).toContain(9_000_700);
expect(legacyIds).not.toContain(9_000_701);
expect(legacyIds).not.toContain(9_000_702);
});
it("distinguishes one-letter title from two-letter title when searching one letter", async () => {
await DB.insertInto("song")
.values([
{
id: makeSongId(710),
legacy_id: 9_000_710,
game_group: "iidx",
title: "A",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(711),
legacy_id: 9_000_711,
game_group: "iidx",
title: "AA",
artist: "Y",
fts_document: "",
data: JSON.stringify({}),
},
])
.execute();
const one = await SearchSongsForGameFtsAndTrgm("iidx", "A", 20);
expect(one.map((r) => r.legacy_id)).toContain(9_000_710);
expect(one.map((r) => r.legacy_id)).not.toContain(9_000_711);
const two = await SearchSongsForGameFtsAndTrgm("iidx", "AA", 20);
expect(two.map((r) => r.legacy_id)).toContain(9_000_711);
expect(two.map((r) => r.legacy_id)).not.toContain(9_000_710);
});
it("matches exact search_term when query is within strict length", async () => {
const sid = makeSongId(720);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_720,
game_group: "iidx",
title: "Long Title",
artist: "Z",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("song_search_term")
.values({ song_id: sid, search_term: "V" })
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "V", 10);
expect(res.some((r) => r.id === sid)).toBe(true);
});
it("still uses substring trgm for three-character queries when FTS is weak", async () => {
expect(SHORT_QUERY_STRICT_MAX_LEN).toBe(2);
await DB.insertInto("song")
.values({
id: makeSongId(730),
legacy_id: 9_000_730,
game_group: "iidx",
title: "ZzzUniqueToken",
artist: "Z",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Zzz", 10);
expect(res.some((r) => r.legacy_id === 9_000_730)).toBe(true);
});
});
describe("SearchSongsForGameFtsAndTrgm (hostile / injection-shaped input)", () => {
it("does not change song row count when search strings look like SQL injection", async () => {
await DB.insertInto("song")
@@ -276,6 +405,284 @@ describe("SearchSpecificGameSongs", () => {
});
});
describe("IIDX 2dxtraSet exclusion from search", () => {
it("excludes songs that only have charts with 2dxtraSet set", async () => {
const sid = makeSongId(860);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_860,
game_group: "iidx",
title: "Only2dxtraSearchToken",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values({
id: makeChartId(860),
legacy_id: "a".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "ANOTHER",
data: JSON.stringify({ "2dxtraSet": "test-set", notecount: 100 }),
})
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Only2dxtraSearchToken", 20);
expect(res.some((r) => r.legacy_id === 9_000_860)).toBe(false);
});
it("includes iidx songs with at least one non-2dxtra chart", async () => {
const sid = makeSongId(861);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_861,
game_group: "iidx",
title: "Mixed2dxtraSearchToken",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values([
{
id: makeChartId(861),
legacy_id: "b".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "ANOTHER",
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
},
{
id: makeChartId(862),
legacy_id: "c".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "11",
level_num: 11,
is_primary: false,
difficulty: "HYPER",
data: JSON.stringify({ "2dxtraSet": null, notecount: 80 }),
},
])
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Mixed2dxtraSearchToken", 20);
expect(res.some((r) => r.legacy_id === 9_000_861)).toBe(true);
});
it("GetChartsBySongPgId omits 2dxtra charts when omit2dxtraCharts is true", async () => {
const sid = makeSongId(870);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_870,
game_group: "iidx",
title: "ChartFilter",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values([
{
id: makeChartId(871),
legacy_id: "d".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "ANOTHER",
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
},
{
id: makeChartId(872),
legacy_id: "e".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "11",
level_num: 11,
is_primary: false,
difficulty: "HYPER",
data: JSON.stringify({ notecount: 80 }),
},
])
.execute();
const all = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870);
const filtered = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870, {
omit2dxtraCharts: true,
});
expect(all).toHaveLength(2);
expect(filtered).toHaveLength(1);
expect(filtered[0]?.difficulty).toBe("HYPER");
});
it("SearchGlobalGameSongsAndCharts never returns 2dxtra charts for iidx", async () => {
const sid = makeSongId(880);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_880,
game_group: "iidx",
title: "GlobalSearch2dxtraToken",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values([
{
id: makeChartId(880),
legacy_id: "f".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "ANOTHER",
data: JSON.stringify({ "2dxtraSet": "set-a", notecount: 100 }),
},
{
id: makeChartId(881),
legacy_id: "0".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "11",
level_num: 11,
is_primary: false,
difficulty: "HYPER",
data: JSON.stringify({ notecount: 80 }),
},
])
.execute();
const rows = await SearchGlobalGameSongsAndCharts(
"iidx",
"GlobalSearch2dxtraToken",
"SP",
20,
);
expect(rows.length).toBeGreaterThan(0);
expect(
rows.every((r) => {
const v = (r.chart.data as Record<string, unknown>)["2dxtraSet"];
return v === null || v === undefined;
}),
).toBe(true);
expect(rows.some((r) => r.chart.difficulty === "HYPER")).toBe(true);
});
it("FindChartsOnPopularity excludes 2dxtra charts for iidx", async () => {
const sid = makeSongId(890);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_890,
game_group: "iidx",
title: "Pop2dxtraToken",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values([
{
id: makeChartId(890),
legacy_id: "1".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "12",
level_num: 12,
is_primary: true,
difficulty: "ANOTHER",
data: JSON.stringify({ "2dxtraSet": "x", notecount: 100 }),
},
{
id: makeChartId(891),
legacy_id: "2".repeat(40),
game: "iidx-sp",
song_id: sid,
level: "11",
level_num: 11,
is_primary: false,
difficulty: "HYPER",
data: JSON.stringify({ notecount: 80 }),
},
])
.execute();
const charts = await FindChartsOnPopularity("iidx", "SP", [9_000_890], 0, 100);
expect(charts.length).toBe(1);
expect(charts[0]?.difficulty).toBe("HYPER");
expect((charts[0]?.data as { "2dxtraSet"?: string | null })["2dxtraSet"] ?? null).toBeNull();
});
it("does not apply the iidx 2dxtra song rule to other game groups", async () => {
const sid = makeSongId(900);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_900,
game_group: "sdvx",
title: "Sdvx2dxtraKeyInJson",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("chart")
.values({
id: makeChartId(900),
legacy_id: "3".repeat(40),
game: "sdvx",
song_id: sid,
level: "18",
level_num: 18,
is_primary: true,
difficulty: "EXHAUST",
data: JSON.stringify({ "2dxtraSet": "ignored-for-sdvx", notecount: 100 }),
})
.execute();
const res = await SearchSongsForGameFtsAndTrgm("sdvx", "Sdvx2dxtraKeyInJson", 20);
expect(res.some((r) => r.legacy_id === 9_000_900)).toBe(true);
});
});
describe("SearchSongsForGameFtsAndTrgm (real seed subset)", () => {
it.skipIf(!seedsJsonAvailable())(
"finds known IIDX titles from a small songs-iidx slice",
@@ -10,6 +10,37 @@ export const MAX_SONG_SEARCH_RESULTS_PER_GAME = 100;
/** Use trigram / ILIKE supplement when the query is this short or FTS returns nothing. */
const SHORT_QUERY_LEN = 3;
/**
* Queries this short match almost every row if we use substring `ILIKE '%q%'` / trgm.
* For `len <=` this value we only add **exact** matches (title, artist, search_term, alt_title)
* and skip substring trgm; FTS still runs first.
*/
export const SHORT_QUERY_STRICT_MAX_LEN = 2;
/**
* IIDX-only: exclude songs whose **only** IIDX charts are 2dxtra (`data.2dxtraSet` set).
* Songs with no `chart` rows yet, or with at least one non-2dxtra IIDX chart, stay searchable.
*/
function iidxSongSearchableWith2dxtraRuleSql(game: GameGroup) {
if (game !== "iidx") {
return sql`true`;
}
return sql`(
NOT EXISTS (
SELECT 1 FROM chart c
WHERE c.song_id = song.id
AND (c.game)::text LIKE 'iidx-%'
)
OR EXISTS (
SELECT 1 FROM chart c
WHERE c.song_id = song.id
AND (c.game)::text LIKE 'iidx-%'
AND (c.data->>'2dxtraSet') IS NULL
)
)`;
}
export type SongSearchRow = {
artist: string;
data: unknown;
@@ -22,6 +53,10 @@ export type SongSearchRow = {
/**
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
* (Zenith-style — no full-table load, no huge IN lists).
*
* Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass
* (title, artist, `song_search_term`, `song_alt_title`) with a boosted rank; substring
* `ILIKE '%q%'` trgm is skipped so single-letter titles like “A” are findable without noise.
*/
export async function SearchSongsForGameFtsAndTrgm(
game: GameGroup,
@@ -46,20 +81,73 @@ export async function SearchSongsForGameFtsAndTrgm(
(ts_rank_cd(textsearch, websearch_to_tsquery('simple', ${q})))::float8 AS rank
FROM song
WHERE game_group = ${game}
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
AND textsearch @@ websearch_to_tsquery('simple', ${q})
ORDER BY rank DESC
LIMIT ${cap}
`.execute(DB);
const needTrgm =
ftsRows.length < cap && (q.length <= SHORT_QUERY_LEN || ftsRows.length === 0);
const strictShort = q.length <= SHORT_QUERY_STRICT_MAX_LEN;
if (!needTrgm) {
return ftsRows;
const exactRows = strictShort
? (
await sql<SongSearchRow>`
SELECT
song.id,
song.legacy_id,
song.title,
song.artist,
song.data,
10.0::float8 AS rank
FROM song
WHERE song.game_group = ${game}
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
AND (
lower(song.title) = lower(${q})
OR lower(song.artist) = lower(${q})
OR EXISTS (
SELECT 1
FROM song_search_term st
WHERE st.song_id = song.id AND lower(st.search_term) = lower(${q})
)
OR EXISTS (
SELECT 1
FROM song_alt_title at
WHERE at.song_id = song.id AND lower(at.alt_title) = lower(${q})
)
)
LIMIT ${cap}
`.execute(DB)
).rows
: [];
const mergedBeforeTrgm = new Map<string, SongSearchRow>();
for (const r of ftsRows) {
mergedBeforeTrgm.set(r.id, r);
}
const excludeIds = ftsRows.map((r) => r.id);
const trgmLimit = cap - ftsRows.length;
for (const r of exactRows) {
const existing = mergedBeforeTrgm.get(r.id);
if (!existing || r.rank > existing.rank) {
mergedBeforeTrgm.set(r.id, r);
}
}
const mergedList = [...mergedBeforeTrgm.values()].sort((a, b) => b.rank - a.rank);
const needTrgm =
mergedList.length < cap &&
!strictShort &&
(q.length <= SHORT_QUERY_LEN || ftsRows.length === 0);
if (!needTrgm) {
return mergedList.slice(0, cap);
}
const excludeIds = mergedList.map((r) => r.id);
const trgmLimit = cap - mergedList.length;
const likeEsc = EscapeForILIKE(q.toLowerCase());
const pat = `%${likeEsc}%`;
@@ -79,6 +167,7 @@ export async function SearchSongsForGameFtsAndTrgm(
)::float8 AS rank
FROM song
WHERE game_group = ${game}
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
AND (
title ILIKE ${pat}
OR artist ILIKE ${pat}
@@ -101,6 +190,7 @@ export async function SearchSongsForGameFtsAndTrgm(
)::float8 AS rank
FROM song
WHERE game_group = ${game}
AND ${iidxSongSearchableWith2dxtraRuleSql(game)}
AND id NOT IN (${sql.join(excludeIds)})
AND (
title ILIKE ${pat}
@@ -113,7 +203,7 @@ export async function SearchSongsForGameFtsAndTrgm(
const byId = new Map<string, SongSearchRow>();
for (const r of ftsRows) {
for (const r of mergedList) {
byId.set(r.id, r);
}
+43 -35
View File
@@ -2,9 +2,12 @@ import type { FilterQuery } from "mongodb";
import type { ICollection } from "monk";
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "#lib/db-formats/song-search";
import {
LoadSongChildrenForPgIds,
MAX_SONG_SEARCH_RESULTS_PER_GAME,
SearchSongsForGameFtsAndTrgm,
} from "#lib/db-formats/song-search";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
import { log } from "#lib/log/log";
import { TachiConfig } from "#lib/setup/config";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
@@ -15,7 +18,6 @@ import { GetOnlineCutoff } from "#utils/user";
import { sql } from "kysely";
import {
type ChartDocument,
CreateSongMap,
type FolderDocument,
type GameGroup,
GamePTToV3,
@@ -236,7 +238,9 @@ export async function SearchSpecificGameSongsAndCharts(
return Promise.resolve([] as Array<ChartDocument>);
}
return GetChartsBySongPgId(v3Game, pgId, song.id);
return GetChartsBySongPgId(v3Game, pgId, song.id, {
omit2dxtraCharts: game === "iidx",
});
}),
);
@@ -247,12 +251,15 @@ export async function SearchSpecificGameSongsAndCharts(
/**
* Search this Game/GPTs songs and charts, but globally.
*
* Returns at most `limit` **chart** rows per GPT (same cap as song search). Without this,
* N matched songs × charts per song could return a huge payload from `/api/v1/search`.
*/
export async function SearchGlobalGameSongsAndCharts(
game: GameGroup,
search: string,
playtype?: Playtype,
limit = 100,
limit = MAX_SONG_SEARCH_RESULTS_PER_GAME,
): Promise<Array<{ chart: ChartDocument; playcount: integer; song: SongDocument }>> {
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
@@ -262,25 +269,42 @@ export async function SearchGlobalGameSongsAndCharts(
const v3Game = GamePTToV3(game, playtype);
const chartLists = await Promise.all(
songs.map((song) => {
const pgId = pgIdByLegacyId.get(song.id);
const output: Array<{ chart: ChartDocument; playcount: integer; song: SongDocument }> = [];
if (!pgId) {
return Promise.resolve([] as Array<ChartDocument>);
for (const song of songs) {
if (output.length >= limit) {
break;
}
const pgId = pgIdByLegacyId.get(song.id);
if (!pgId) {
continue;
}
// eslint-disable-next-line no-await-in-loop -- stop after enough charts; avoids loading every song's charts when the cap is already reached.
const songCharts = await GetChartsBySongPgId(v3Game, pgId, song.id, {
omit2dxtraCharts: game === "iidx",
});
for (const chart of songCharts) {
if (output.length >= limit) {
break;
}
return GetChartsBySongPgId(v3Game, pgId, song.id);
}),
);
output.push({
song,
chart,
playcount: 0,
});
}
}
const charts = chartLists.flat();
if (charts.length === 0) {
if (output.length === 0) {
return [];
}
const chartLegacyIds = charts.map((c) => c.chartID);
const chartLegacyIds = output.map((o) => o.chart.chartID);
const playcountRows = await DB.selectFrom("score")
.innerJoin("chart", "chart.id", "score.chart_id")
@@ -291,24 +315,8 @@ export async function SearchGlobalGameSongsAndCharts(
const playcountLookup = Object.fromEntries(playcountRows.map((r) => [r.legacy_id, r.playcount]));
const songMap = CreateSongMap(songs);
const output = [];
for (const chart of charts) {
const song = songMap.get(chart.songID);
if (!song) {
log.warn(`Failed to find parent song for ${chart.songID} (${game})? Skipping.`);
continue;
}
output.push({
song,
chart,
playcount: playcountLookup[chart.chartID] ?? 0,
});
for (const row of output) {
row.playcount = playcountLookup[row.chart.chartID] ?? 0;
}
return output;
@@ -2,7 +2,7 @@ import type { Game } from "tachi-db";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { sql } from "kysely";
import { sql, type SqlBool } from "kysely";
import {
type ChartDocument,
type ChartDocumentData,
@@ -252,7 +252,8 @@ export async function FindChartsOnPopularity(
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.leftJoin("score", "score.chart_id", "chart.id")
.where("chart.game", "=", v3Game as Game);
.where("chart.game", "=", v3Game as Game)
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`);
if (songIDs && songIDs.length > 0) {
q = q.where("song.legacy_id", "in", songIDs);