156 lines
4.3 KiB
TypeScript
156 lines
4.3 KiB
TypeScript
import { sql } from '@databases/pg'
|
|
import { FastifyPluginAsync } from 'fastify'
|
|
import S from 'jsonschema-definer'
|
|
|
|
import { db } from '../shared'
|
|
import { QSplit, qParseNum, pgOp } from '../util/search'
|
|
|
|
export const searchRouter: FastifyPluginAsync = async (f) => {
|
|
{
|
|
const sQuery = S.shape({
|
|
q: S.string().minLength(1),
|
|
offset: S.integer().optional(),
|
|
limit: S.integer().optional(),
|
|
})
|
|
|
|
const sResponse = S.shape({
|
|
result: S.list(
|
|
S.shape({
|
|
entry: S.string(),
|
|
type: S.string(),
|
|
}).additionalProperties(true)
|
|
),
|
|
offset: S.integer(),
|
|
limit: S.integer(),
|
|
count: S.integer(),
|
|
})
|
|
|
|
const makeEntry = new QSplit({
|
|
default(v) {
|
|
if (v.includes('*')) {
|
|
return sql`"entry" && (
|
|
SELECT array_agg("entry") FROM "frequency" WHERE "entry" LIKE ${'%' + v.replace('*', '%') + '%'}
|
|
)`
|
|
}
|
|
|
|
if (/[\p{sc=Han}\p{sc=Katakana}\p{sc=Hiragana}]/u.test(v)) {
|
|
return sql`(${sql.join(
|
|
[
|
|
this.fields['entry']![':']!(v),
|
|
this.fields['reading']![':']!(v),
|
|
this.fields['translation']![':']!(v),
|
|
],
|
|
' OR '
|
|
)})`
|
|
}
|
|
|
|
if (/[^\p{L}\p{N}\p{M} :-]/u.test(v)) {
|
|
return null
|
|
}
|
|
|
|
return sql`(${sql.join(
|
|
[
|
|
this.fields['reading']![':']!(v),
|
|
this.fields['translation']![':']!(v),
|
|
],
|
|
' OR '
|
|
)})`
|
|
},
|
|
fields: {
|
|
entry: { ':': (v) => sql`"entry" &@ ${v}` },
|
|
kana: { ':': (v) => sql`"reading" &@ ${v}` },
|
|
reading: { ':': (v) => sql`"reading" &@ ${v}` },
|
|
kunyomi: {
|
|
':': (v) => sql`"reading" ${pgOp} ${`paths @ ".kunyomi" && string @ ${JSON.stringify(v)}`}`,
|
|
},
|
|
onyomi: {
|
|
':': (v) => sql`"reading" ${pgOp} ${`paths @ ".onyomi" && string @ ${JSON.stringify(v)}`}`,
|
|
},
|
|
nanori: {
|
|
':': (v) => sql`"reading" ${pgOp} ${`paths @ ".nanori" && string @ ${JSON.stringify(v)}`}`,
|
|
},
|
|
english: {
|
|
':': (v) => sql`"translation" ${pgOp} ${`paths @ ".en" && string @ ${JSON.stringify(v)}`}`,
|
|
},
|
|
translation: { ':': (v) => sql`"translation" &@ ${v}` },
|
|
type: {
|
|
':': (v) => sql`"_type" @> ARRAY[${v.toLocaleLowerCase()}]`,
|
|
},
|
|
},
|
|
})
|
|
|
|
const makeLevel = new QSplit({
|
|
default: () => null,
|
|
fields: {
|
|
level: qParseNum(sql`("wanikani.subjects"."data" -> 'level')::int`),
|
|
},
|
|
})
|
|
|
|
f.get<{
|
|
Querystring: typeof sQuery.type
|
|
}>(
|
|
'/',
|
|
{
|
|
schema: {
|
|
querystring: sQuery.valueOf(),
|
|
response: {
|
|
200: sResponse.valueOf(),
|
|
},
|
|
},
|
|
},
|
|
async (req): Promise<typeof sResponse.type> => {
|
|
const { q, offset = 0, limit = 5 } = req.query
|
|
|
|
const entryCond = makeEntry.parse(q)
|
|
const levelCond = makeLevel.parse(q)
|
|
|
|
const [r] = await db.query(sql`
|
|
WITH cte AS (
|
|
SELECT DISTINCT ON (unnest, "type")
|
|
unnest "entry", "type", "priority", "frequency"
|
|
FROM (
|
|
SELECT
|
|
unnest("entry"), *
|
|
FROM "entry"
|
|
${
|
|
levelCond
|
|
? sql`JOIN "wanikani.subjects" ON
|
|
"entry"."_type" @> ARRAY["wanikani.subjects"."object"]
|
|
AND "entry"."entry" @> ARRAY["wanikani.subjects"."data" ->> 'characters']`
|
|
: sql``
|
|
}
|
|
WHERE
|
|
${entryCond || sql`TRUE`}
|
|
AND ${levelCond || sql`TRUE`}
|
|
) t1
|
|
JOIN "frequency" ON "frequency"."entry" = unnest
|
|
)
|
|
|
|
SELECT
|
|
(SELECT array_agg(jsonb_build_object(
|
|
'entry', "entry",
|
|
'type', "type",
|
|
'frequency', "frequency"
|
|
)) FROM (
|
|
SELECT * FROM cte
|
|
ORDER BY "priority" DESC, "frequency" DESC
|
|
LIMIT ${limit} OFFSET ${offset}
|
|
) t1)||'{}'::jsonb[] "result",
|
|
${offset} "offset",
|
|
${limit} "limit",
|
|
(SELECT count(*) FROM cte) "count"
|
|
`)
|
|
|
|
return (
|
|
r || {
|
|
result: [],
|
|
offset,
|
|
limit,
|
|
count: 0,
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|