ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
14 KiB
TypeScript
257 lines
14 KiB
TypeScript
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#94(2026-08-12)
|
||
//
|
||
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
|
||
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
|
||
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
|
||
//
|
||
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
|
||
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
|
||
//
|
||
// 測試策略:**用真 SQLite 跑真的 SQL**(node:sqlite,與 library-map/embed-backfill 同款 adapter)。
|
||
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
|
||
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
|
||
//
|
||
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
|
||
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfill/library-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 原檔
|
||
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
|
||
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 ?';
|
||
// 0007 之後池裡多了機制節點(sys_* 啟動常數等有 content 的列)——比照 searchEntries 的
|
||
// 機制列隔離謂詞排除,讓「全庫」仍然指使用者的語料庫,前後對照的語意不變。
|
||
const res = await db
|
||
.prepare(`SELECT content FROM entries WHERE src_id IS NULL AND entry_type NOT IN ('record','sheet','field','system') AND ${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('buildContentLike/buildSearchScore 產生的謂詞都含 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-10,Arcrun#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
|
||
}
|
||
});
|
||
});
|