merge: 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)

總管逐筆審過兩筆,並自己跑了測試(那條線被權限閘擋住沒能執行):
  前後對照  查 100% 改前撈回「共有 100 個待辦項目」→ 改後只回真的含 100% 的
            查 owner_id 改前連 ownerXid 也中 → 改後只回字面命中
            單打 % 或 _ 改前整個庫都回來 → 改後只回字面命中
  反向驗證  拿掉跳脫 → 8 failed(紅的正是病徵本身)
            拿掉 ESCAPE → 5 failed(含既有 search-long-query 兩條)
  既有測試  18 檔 197 pass → 19 檔 213 pass

它連帶處理了一個我沒想到的:跳脫會讓 pattern 變長,所以長度上限改用「跳脫後」的
長度算——否則打 48 個 % 會產生 98 bytes 的 pattern,退回 08-03 修掉的那個 HTTP 500。

誠實標記:只在本機真 SQLite 上驗過,沒對任何線上實例跑過。
同款漏洞另有兩處未動(library-map.ts:169、library-backfill.ts:75-76,吃維運端前綴參數)。
This commit is contained in:
uncle6me-web
2026-08-12 12:21:48 +08:00
3 changed files with 325 additions and 15 deletions
+66 -13
View File
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
//
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
// `%``_` 單打 → `%%%``%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
//
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
//
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
//
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
// 一旦宣告了 ESCAPE`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
// SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
//
// 為什麼**只有這三個**SQLite 的 LIKE 萬用字元就只有 `%` 和 `_``[...]`、`?`、`*`
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
//
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
const LIKE_ESCAPE = '\\';
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
export function escapeLikeLiteral(s: string): string {
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
}
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (likeBytes(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
@@ -251,8 +302,8 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
conds: terms.map(() => CONTENT_LIKE),
params: terms.map(likePattern),
split: true,
};
}
@@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan {
if (terms.length === 0) {
const m = buildContentLike(trimmed);
return {
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
scoreParams: m.params,
terms: [],
legacyShape: true,
@@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan {
const parts: string[] = [];
const params: string[] = [];
for (const { term, weight } of terms) {
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
params.push(`%${term}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
params.push(likePattern(term));
}
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
const single = terms.length === 1 && terms[0].term === trimmed;
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
const bonus = terms.reduce((s, t) => s + t.weight, 0);
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
params.push(`%${trimmed}%`);
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
params.push(likePattern(trimmed));
}
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
@@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 2026-08-12Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral ESCAPE 宣告)
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
// 2026-08-10q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
export async function searchEntries(
+253
View File
@@ -0,0 +1,253 @@
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#942026-08-12
//
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
//
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
//
// 測試策略:**用真 SQLite 跑真的 SQL**node:sqlite,與 library-mapembed-backfill 同款 adapter)。
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
//
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfilllibrary-backfill 等既有測試同慣例。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import {
escapeLikeLiteral,
buildContentLike,
buildSearchScore,
searchEntries,
createEntry,
} from '../src/actions/entry-crud';
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
function stmt(sql: string, params: unknown[]) {
return {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
};
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
const legacyPattern = (q: string) => `%${q}%`;
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
const res = await db
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
.bind(pattern)
.all<{ content: string }>();
return (res.results ?? []).map((x) => x.content);
}
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
const sqls: string[] = [];
const spy = {
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
} as unknown as D1Database;
return { spy, sqls };
}
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
const CORPUS = [
'毛利率 100% 達成', // 含字面 %
'共有 100 個待辦項目', // 含 100 但不含 %`%100%%` 會誤中它
'owner_id 是租戶隔離的欄位', // 含字面 _
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
'完全無關的一筆內容', // 對照組:什麼都不該中
];
async function seeded(): Promise<D1Database> {
const db = makeSqliteD1();
for (const [i, content] of CORPUS.entries()) {
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
}
return db;
}
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
const db = await seeded();
const before = await likeHits(db, legacyPattern('100%'), false);
const after = await likeHits(db, buildContentLike('100%').params[0], true);
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
});
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
const db = await seeded();
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
'owner_id 是租戶隔離的欄位',
'ownerXid 是打錯的欄位名', // ← 病徵
]);
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
});
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
const db = await seeded();
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
'owner_id 是租戶隔離的欄位',
'路徑 C:\\_temp 底下',
]);
});
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
const db = await seeded();
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
'owner_id 是租戶隔離的欄位',
]);
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
);
});
});
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
const db = await seeded();
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下',
]);
});
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
const db = await seeded();
// `%C:\\_temp%``\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
'路徑 C:\\_temp 底下',
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
]);
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
'路徑 C:\\_temp 底下',
]);
});
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
const db = await seeded();
// 原始寫法(一個都不跳脫)= pattern `%100\%%``\%`=字面 %、尾巴那個 `%`=萬用字元
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
});
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
expect(escapeLikeLiteral('100%')).toBe('100\\%');
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
});
});
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
it('buildContentLikebuildSearchScore 產生的謂詞都含 ESCAPE', () => {
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
});
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
const { spy, sqls } = sqlSpy(await seeded());
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
expect(sqls.length).toBeGreaterThan(0);
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
});
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
const db = await seeded();
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
});
});
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
expect(escapeLikeLiteral(q)).toBe(q);
}
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
});
it('斷詞(08-10Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
expect.arrayContaining(['%Gemini%', '%逃生口%']),
);
});
});
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
const qs = [
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
'_'.repeat(60),
'\\'.repeat(60),
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
];
for (const q of qs) {
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
const plan = buildSearchScore(q);
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
const q = '%'.repeat(48);
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
const m = buildContentLike(q);
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
const db = await seeded();
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
expect(p).not.toContain('\uFFFD');
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
}
});
});
+6 -2
View File
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 上限
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
const m = buildContentLike('語意檢索');
expect(m.split).toBe(false);
expect(m.conds).toEqual(['content LIKE ?']);
expect(m.conds).toEqual([LIKE_PRED]);
expect(m.params).toEqual(['%語意檢索%']);
});
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});