dsh-plugin-dbgraph-store 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/resolve.js ADDED
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Offline semantic resolution: user words in, one table plus one parameterized `WHERE` out.
3
+ *
4
+ * The resolver is deliberately conservative in one direction and generous in the other: it accepts a
5
+ * loose list of candidates (a wrong word in the alias table only costs one ranked alternative) but
6
+ * refuses to answer when more than one table fits, when the best fit is below the confidence gate, or
7
+ * when the request names no table, entity, or condition at all. The failure it must never produce is a
8
+ * confident `SELECT *` over the wrong table.
9
+ *
10
+ * Scoring, in one sentence: a candidate's score is `0.85 × best evidence + 0.15 × concept coverage`,
11
+ * where evidence is a term weight (a table's own name beats a comment, a column reached through an
12
+ * alias is weighed again at 0.8 because it is weaker evidence about the *table*), and concepts are the
13
+ * user's words after alias expansion. The two knobs a deployment may retune are the gate
14
+ * (`minConfidence`, default 0.6) and the tie margin (`ambiguityGap`, default 0.15).
15
+ * @module dsh-plugin-dbgraph-store/resolve
16
+ */
17
+ import { quoteIdentifier, searchMatchQuery, tokenizeForSearch, } from 'dsh-plugin-dbgraph-service';
18
+ import { aliasGroupTokens, aliasGroupsIn } from "./aliases.js";
19
+ import { hasCjk, isAsciiToken, levenshteinWithin, normalizeIdentifier } from "./normalize.js";
20
+ import { displayName, ftsObjectIds, loadColumns, loadObjects, lookupObject } from "./reader.js";
21
+ /** How much a column hit counts as evidence about the table that owns it. */
22
+ export const COLUMN_EVIDENCE_FACTOR = 0.8;
23
+ /** Weight of the single best evidence token in the score. */
24
+ export const BEST_WEIGHT_SHARE = 0.85;
25
+ /** Weight of concept coverage in the score. */
26
+ export const COVERAGE_SHARE = 0.15;
27
+ /** When only the FTS index recalls an object, its score starts here. */
28
+ export const FTS_FALLBACK_TOP = 0.62;
29
+ /** Rank penalty per additional FTS-only candidate. */
30
+ export const FTS_FALLBACK_STEP = 0.02;
31
+ /** Lowest score an FTS-only candidate is given. */
32
+ export const FTS_FALLBACK_FLOOR = 0.5;
33
+ /** Largest number of distinct tokens one entity lookup will bind. */
34
+ const MAX_QUERY_TOKENS = 800;
35
+ /** SQLite's usual variable bound is far above this, but chunking keeps the statement small. */
36
+ const TOKEN_CHUNK = 400;
37
+ /** Columns shown in a "these are the available columns" explanation. */
38
+ const AVAILABLE_COLUMNS_SHOWN = 40;
39
+ /** Columns summarised in a resolved reason. */
40
+ const REASON_COLUMNS_SHOWN = 8;
41
+ /**
42
+ * Shortest folded field a fuzzy match may act on.
43
+ *
44
+ * At four or five characters one substitution is another plausible column (`c0520`/`c0020`,
45
+ * `flag`/`flat`), and answering with a column nobody asked for is the one failure the resolver must
46
+ * not produce — a miss is recoverable, a silent substitution is not.
47
+ */
48
+ const FUZZY_MIN_LENGTH = 6;
49
+ /** Default resolver tuning. */
50
+ export const DEFAULT_RESOLVE_OPTIONS = {
51
+ aliases: [],
52
+ minConfidence: 0.6,
53
+ ambiguityGap: 0.15,
54
+ maxCandidates: 5,
55
+ maxColumns: 500,
56
+ };
57
+ /**
58
+ * Round to the precision the resolver's margin comparisons assume.
59
+ * @param value - a 0..1 score.
60
+ * @returns the score to three decimals.
61
+ */
62
+ function round3(value) {
63
+ return Math.round(value * 1000) / 1000;
64
+ }
65
+ /**
66
+ * The result when the request names nothing that could identify a table.
67
+ * @returns the `missing_predicate` resolution.
68
+ */
69
+ function missingPredicate() {
70
+ return {
71
+ status: 'missing_predicate',
72
+ reason: '缺少定位条件:没有 object(表名)、entity(业务实体)或 where(字段条件)。'
73
+ + '不会生成全表查询;请补充其中一项。',
74
+ confidence: 0,
75
+ };
76
+ }
77
+ /**
78
+ * Whether a user's word occurs in the entity text.
79
+ * @param entity - the raw entity string.
80
+ * @param foldedEntity - `normalizeIdentifier(entity)`.
81
+ * @param entityTokens - `tokenizeForSearch(entity)` as a set.
82
+ * @param word - one alias word.
83
+ * @returns true when the entity mentions the word.
84
+ */
85
+ function entityMentionsWord(entity, foldedEntity, entityTokens, word) {
86
+ const normalizedWord = word.normalize('NFKC').toLowerCase().trim();
87
+ if (normalizedWord === '')
88
+ return false;
89
+ const wordTokens = tokenizeForSearch(normalizedWord);
90
+ if (wordTokens.length === 0)
91
+ return false;
92
+ if (hasCjk(normalizedWord))
93
+ return entity.normalize('NFKC').toLowerCase().includes(normalizedWord);
94
+ if (wordTokens.length === 1 && isAsciiToken(wordTokens[0])) {
95
+ return entityTokens.has(wordTokens[0]);
96
+ }
97
+ return foldedEntity.includes(normalizeIdentifier(normalizedWord));
98
+ }
99
+ /**
100
+ * Split an entity into its CJK runs, which is the only segmentation available without a dictionary.
101
+ * @param text - the entity.
102
+ * @returns contiguous CJK runs, in order.
103
+ */
104
+ function cjkRuns(text) {
105
+ const runs = [];
106
+ let current = '';
107
+ for (const character of text.normalize('NFKC')) {
108
+ if (hasCjk(character)) {
109
+ current += character;
110
+ continue;
111
+ }
112
+ if (current !== '') {
113
+ runs.push(current);
114
+ current = '';
115
+ }
116
+ }
117
+ if (current !== '')
118
+ runs.push(current);
119
+ return runs;
120
+ }
121
+ /**
122
+ * Turn the user's entity into the concepts the score is averaged over.
123
+ *
124
+ * A matched alias group is one concept (its whole vocabulary is the evidence), and the words the group
125
+ * did not account for keep their own concepts, so `系统用户` is "user" plus whatever `系统` contributes.
126
+ * @param entity - what the user typed.
127
+ * @param aliases - the alias table.
128
+ * @returns concepts, in a stable order.
129
+ */
130
+ export function buildEntityConcepts(entity, aliases) {
131
+ const entityTokens = tokenizeForSearch(entity);
132
+ const entityTokenSet = new Set(entityTokens);
133
+ const foldedEntity = normalizeIdentifier(entity);
134
+ const matchedGroups = [];
135
+ for (const group of aliases) {
136
+ if (group.words.some(word => entityMentionsWord(entity, foldedEntity, entityTokenSet, word))) {
137
+ matchedGroups.push(group);
138
+ }
139
+ }
140
+ const concepts = [];
141
+ const consumed = new Set();
142
+ for (const group of matchedGroups) {
143
+ const tokens = new Set(aliasGroupTokens(group));
144
+ concepts.push({ tokens });
145
+ for (const token of tokens) {
146
+ if (entityTokenSet.has(token))
147
+ consumed.add(token);
148
+ }
149
+ }
150
+ for (const token of entityTokens) {
151
+ if (isAsciiToken(token) && !consumed.has(token))
152
+ concepts.push({ tokens: new Set([token]) });
153
+ }
154
+ for (const run of cjkRuns(entity)) {
155
+ const leftover = tokenizeForSearch(run).filter(token => !consumed.has(token));
156
+ if (leftover.length > 0)
157
+ concepts.push({ tokens: new Set(leftover) });
158
+ }
159
+ return concepts;
160
+ }
161
+ /**
162
+ * Load the `terms` rows behind a set of tokens, resolving a column row's object even when the writer
163
+ * left `object_id` NULL.
164
+ * @param db - the open index.
165
+ * @param tokens - query tokens.
166
+ * @returns evidence rows.
167
+ */
168
+ function loadTermEvidence(db, tokens) {
169
+ const unique = [...new Set(tokens)].slice(0, MAX_QUERY_TOKENS);
170
+ const evidence = [];
171
+ for (let offset = 0; offset < unique.length; offset += TOKEN_CHUNK) {
172
+ const chunk = unique.slice(offset, offset + TOKEN_CHUNK);
173
+ const placeholders = chunk.map(() => '?').join(', ');
174
+ const rows = db
175
+ .prepare(`SELECT t.term AS term, t.weight AS weight, t.object_id AS object_id, t.column_id AS column_id,
176
+ COALESCE(t.object_id, c.object_id) AS resolved_object
177
+ FROM terms t LEFT JOIN columns c ON c.id = t.column_id
178
+ WHERE t.term IN (${placeholders})`)
179
+ .all(...chunk);
180
+ for (const row of rows) {
181
+ const term = typeof row.term === 'string' ? row.term : undefined;
182
+ const weight = typeof row.weight === 'number' ? row.weight : undefined;
183
+ const objectId = typeof row.resolved_object === 'number' ? row.resolved_object : undefined;
184
+ if (term === undefined || weight === undefined || objectId === undefined)
185
+ continue;
186
+ evidence.push({
187
+ term,
188
+ weight,
189
+ objectId,
190
+ columnId: typeof row.column_id === 'number' ? row.column_id : null,
191
+ });
192
+ }
193
+ }
194
+ return evidence;
195
+ }
196
+ /**
197
+ * Score every object the entity's concepts touch.
198
+ * @param db - the open index.
199
+ * @param entity - what the user typed.
200
+ * @param options - resolver tuning.
201
+ * @returns scored objects, unsorted.
202
+ */
203
+ function scoreByEntity(db, entity, options) {
204
+ const concepts = buildEntityConcepts(entity, options.aliases);
205
+ if (concepts.length === 0)
206
+ return [];
207
+ const evidence = loadTermEvidence(db, concepts.flatMap(concept => [...concept.tokens]));
208
+ const bestPerConcept = new Map();
209
+ for (const row of evidence) {
210
+ const effective = row.columnId === null ? row.weight : row.weight * COLUMN_EVIDENCE_FACTOR;
211
+ let best = bestPerConcept.get(row.objectId);
212
+ if (best === undefined) {
213
+ best = new Array(concepts.length).fill(0);
214
+ bestPerConcept.set(row.objectId, best);
215
+ }
216
+ for (let index = 0; index < concepts.length; index += 1) {
217
+ if (concepts[index].tokens.has(row.term) && effective > best[index]) {
218
+ best[index] = effective;
219
+ }
220
+ }
221
+ }
222
+ const scored = [];
223
+ for (const [objectId, best] of bestPerConcept) {
224
+ const strongest = Math.max(...best);
225
+ if (strongest <= 0)
226
+ continue;
227
+ const covered = best.filter(value => value > 0).length / concepts.length;
228
+ scored.push({ objectId, score: round3(BEST_WEIGHT_SHARE * strongest + COVERAGE_SHARE * covered) });
229
+ }
230
+ if (scored.length > 0)
231
+ return scored;
232
+ // No term evidence at all: the FTS index is the recall net. Its candidates enter below the top of
233
+ // the gate range, so a single hit can resolve and two near-equal hits stay ambiguous.
234
+ const expression = searchMatchQuery([entity]);
235
+ if (expression === '')
236
+ return [];
237
+ return ftsObjectIds(db, expression, 20).map((objectId, rank) => ({
238
+ objectId,
239
+ score: round3(Math.max(FTS_FALLBACK_FLOOR, FTS_FALLBACK_TOP - rank * FTS_FALLBACK_STEP)),
240
+ }));
241
+ }
242
+ /**
243
+ * The all-columns match list used when the request names no fields.
244
+ * @param columns - the object's columns.
245
+ * @returns one `name` match per column, in declared order.
246
+ */
247
+ function allColumnsAsMatches(columns) {
248
+ return columns.map(column => ({
249
+ column: column.name,
250
+ ...(column.comment === undefined ? {} : { comment: column.comment }),
251
+ matchedBy: 'name',
252
+ score: 1,
253
+ }));
254
+ }
255
+ /**
256
+ * Build one matched-column entry.
257
+ * @param column - the column.
258
+ * @param matchedBy - which rule chose it.
259
+ * @param score - its 0..1 score.
260
+ * @param term - the user's word, when one produced the hit.
261
+ * @returns the entry.
262
+ */
263
+ function columnMatch(column, matchedBy, score, term) {
264
+ return {
265
+ column: column.name,
266
+ ...(column.comment === undefined ? {} : { comment: column.comment }),
267
+ matchedBy,
268
+ term,
269
+ score,
270
+ };
271
+ }
272
+ /**
273
+ * Whether a comment carries every token of a field.
274
+ * @param comment - the column comment.
275
+ * @param fieldTokens - the field's tokens.
276
+ * @returns true when the comment contains them all.
277
+ */
278
+ function commentHasTokens(comment, fieldTokens) {
279
+ if (comment === undefined || fieldTokens.length === 0)
280
+ return false;
281
+ const tokens = new Set(tokenizeForSearch(comment));
282
+ return fieldTokens.every(token => tokens.has(token));
283
+ }
284
+ /**
285
+ * Match one requested field to one column, trying the rules in the order the contract fixes.
286
+ * @param columns - the object's columns.
287
+ * @param field - the user's word.
288
+ * @param aliases - the alias table.
289
+ * @returns the match, or undefined when no tier fits.
290
+ */
291
+ function matchField(columns, field, aliases) {
292
+ const folded = normalizeIdentifier(field);
293
+ const fieldTokens = tokenizeForSearch(field);
294
+ if (folded === '')
295
+ return undefined;
296
+ // 1. exact column name, separator-insensitive.
297
+ const exact = columns.find(column => normalizeIdentifier(column.name) === folded);
298
+ if (exact !== undefined)
299
+ return columnMatch(exact, 'name', 1, field);
300
+ // 2. a token of the column name (user_name ← "name").
301
+ const namePart = columns.find(column => {
302
+ const tokens = new Set(tokenizeForSearch(column.name));
303
+ return fieldTokens.length > 0 && fieldTokens.every(token => tokens.has(token));
304
+ });
305
+ if (namePart !== undefined)
306
+ return columnMatch(namePart, 'name-part', 0.9, field);
307
+ // 3. the column comment contains the field.
308
+ const commentHit = columns.find(column => commentHasTokens(column.comment, fieldTokens));
309
+ if (commentHit !== undefined)
310
+ return columnMatch(commentHit, 'comment', 0.8, field);
311
+ // 4. the field is a synonym of something in the column's name or comment.
312
+ const groups = aliasGroupsIn([field], aliases);
313
+ if (groups.length > 0) {
314
+ for (const column of columns) {
315
+ const tokens = new Set([...tokenizeForSearch(column.name), ...tokenizeForSearch(column.comment ?? '')]);
316
+ for (const group of groups) {
317
+ if (aliasGroupTokens(group).some(token => tokens.has(token))) {
318
+ return columnMatch(column, 'synonym', 0.75, field);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ // 5. one edit away from a long-enough column name or name token, and the single best candidate.
324
+ // A short identifier is excluded (see FUZZY_MIN_LENGTH) and a tie is refused: a substitution
325
+ // that silently answers with another column is worse than saying the field is not there. The
326
+ // floor applies to both sides: a 5-character column one edit away from a 6-character field is
327
+ // exactly the kind of near-miss a loose floor would answer with.
328
+ if (folded.length >= FUZZY_MIN_LENGTH) {
329
+ let best;
330
+ let bestCount = 0;
331
+ for (const column of columns) {
332
+ const candidates = [normalizeIdentifier(column.name), ...tokenizeForSearch(column.name).map(normalizeIdentifier)];
333
+ let columnBest = Number.POSITIVE_INFINITY;
334
+ for (const candidate of candidates) {
335
+ if (candidate.length < FUZZY_MIN_LENGTH)
336
+ continue;
337
+ const distance = levenshteinWithin(folded, candidate, 1);
338
+ if (distance <= 1 && distance < columnBest)
339
+ columnBest = distance;
340
+ }
341
+ if (columnBest === Number.POSITIVE_INFINITY)
342
+ continue;
343
+ if (best === undefined || columnBest < best.distance) {
344
+ best = { column, distance: columnBest };
345
+ bestCount = 1;
346
+ }
347
+ else if (columnBest === best.distance) {
348
+ bestCount += 1;
349
+ }
350
+ }
351
+ if (best !== undefined && bestCount === 1) {
352
+ return columnMatch(best.column, 'fuzzy', 0.55, field);
353
+ }
354
+ }
355
+ return undefined;
356
+ }
357
+ /**
358
+ * Match every requested field, preserving the request's order.
359
+ * @param columns - the object's columns.
360
+ * @param fields - the requested words.
361
+ * @param aliases - the alias table.
362
+ * @returns the matches, the words that did not resolve, and the columns.
363
+ */
364
+ function matchRequestedFields(columns, fields, aliases) {
365
+ const matches = [];
366
+ const missing = [];
367
+ for (const field of fields) {
368
+ const hit = matchField(columns, field, aliases);
369
+ if (hit === undefined)
370
+ missing.push(field);
371
+ else
372
+ matches.push(hit);
373
+ }
374
+ return { matches, missing };
375
+ }
376
+ /**
377
+ * Resolve a `where` key to a column.
378
+ * @param columns - the object's columns.
379
+ * @param key - the caller's column name.
380
+ * @returns the column, when the name really exists on the object.
381
+ */
382
+ function findWhereColumn(columns, key) {
383
+ const folded = normalizeIdentifier(key);
384
+ if (folded === '')
385
+ return undefined;
386
+ return columns.find(column => normalizeIdentifier(column.name) === folded);
387
+ }
388
+ /**
389
+ * Render the available column names for a refusal reason.
390
+ * @param columns - the object's columns.
391
+ * @param limit - how many names to show.
392
+ * @returns a comma-separated list with a truncation note.
393
+ */
394
+ function availableColumnsText(columns, limit = AVAILABLE_COLUMNS_SHOWN) {
395
+ const names = columns.slice(0, limit).map(column => column.name);
396
+ const suffix = columns.length > limit ? ` 等 ${columns.length} 列` : '';
397
+ return names.length === 0 ? '(无)' : `${names.join('、')}${suffix}`;
398
+ }
399
+ /**
400
+ * Render the matched columns for a resolved reason, truncated so a 300-column table cannot flood it.
401
+ * @param matches - the matched columns.
402
+ * @returns the summary.
403
+ */
404
+ function summarizeMatches(matches) {
405
+ const shown = matches.slice(0, REASON_COLUMNS_SHOWN).map(match => {
406
+ const source = match.term === undefined ? match.matchedBy : `${match.matchedBy}←${match.term}`;
407
+ return `${match.column}(${source})`;
408
+ });
409
+ const suffix = matches.length > REASON_COLUMNS_SHOWN ? ` 等 ${matches.length} 列` : '';
410
+ return shown.length === 0 ? '(无)' : `${shown.join('、')}${suffix}`;
411
+ }
412
+ /**
413
+ * Build a `FindCandidate`.
414
+ * @param record - the object.
415
+ * @param score - its 0..1 score.
416
+ * @param columns - the matched columns to surface.
417
+ * @returns the candidate.
418
+ */
419
+ function makeCandidate(record, score, columns) {
420
+ return {
421
+ object: displayName(record),
422
+ kind: record.kind,
423
+ ...(record.comment === undefined ? {} : { comment: record.comment }),
424
+ score,
425
+ columns,
426
+ };
427
+ }
428
+ /**
429
+ * The predicate a resolved request needs.
430
+ * @param engine - the connection's engine, which decides identifier quoting.
431
+ * @param where - resolved (column, value) pairs in request order.
432
+ * @returns the plan, or undefined when there is no condition.
433
+ */
434
+ function buildPredicate(engine, where) {
435
+ if (where.length === 0)
436
+ return undefined;
437
+ const clause = `WHERE ${where
438
+ .map(entry => `${quoteIdentifier(engine, entry.column.name)} = ?`)
439
+ .join(' AND ')}`;
440
+ return { clause, values: where.map(entry => entry.value) };
441
+ }
442
+ /**
443
+ * Assemble the final resolution for an object that was selected.
444
+ *
445
+ * Field and `where` validation happen here rather than at each call site, because every selection path
446
+ * (name, entity, `where`-only) must end in the same rules.
447
+ * @param db - the open index.
448
+ * @param engine - engine name from the index's meta.
449
+ * @param record - the selected object.
450
+ * @param confidence - the object-level confidence.
451
+ * @param lead - how the object was selected, prefixed to the reason.
452
+ * @param fields - requested fields.
453
+ * @param whereEntries - requested predicate entries, in order.
454
+ * @param options - resolver tuning.
455
+ * @returns the resolution.
456
+ */
457
+ function buildResolved(db, engine, record, confidence, lead, fields, whereEntries, options) {
458
+ const loaded = loadColumns(db, record.id, options.maxColumns + 1);
459
+ const columns = loaded.slice(0, options.maxColumns);
460
+ // Saying "no such column" about a column that exists but fell outside the cap would be a wrong
461
+ // answer, so the refusal names the bound whenever it applied.
462
+ const bound = loaded.length > options.maxColumns
463
+ ? `(只检查了前 ${options.maxColumns} 列,该表还有更多列未参与匹配)`
464
+ : '';
465
+ const display = displayName(record);
466
+ const where = [];
467
+ for (const [key, value] of whereEntries) {
468
+ const column = findWhereColumn(columns, key);
469
+ if (column === undefined) {
470
+ return {
471
+ status: 'not_found',
472
+ reason: `表 ${display} 没有字段“${key}”(where 条件用);可用列:${availableColumnsText(columns)}${bound}。`,
473
+ confidence: 0,
474
+ resolved: makeCandidate(record, confidence, []),
475
+ };
476
+ }
477
+ where.push({ column, value });
478
+ }
479
+ let matches;
480
+ if (fields.length === 0) {
481
+ matches = allColumnsAsMatches(columns);
482
+ }
483
+ else {
484
+ const outcome = matchRequestedFields(columns, fields, options.aliases);
485
+ if (outcome.missing.length > 0) {
486
+ return {
487
+ status: 'not_found',
488
+ reason: `表 ${display} 没有字段${outcome.missing.map(word => `“${word}”`).join('、')};`
489
+ + `可用列:${availableColumnsText(columns)}${bound}。`,
490
+ confidence: 0,
491
+ resolved: makeCandidate(record, confidence, outcome.matches),
492
+ };
493
+ }
494
+ matches = outcome.matches;
495
+ }
496
+ const predicate = buildPredicate(engine, where);
497
+ const reason = `${lead}${display}${record.comment === undefined ? '' : `(${record.comment})`}`
498
+ + `;字段:${summarizeMatches(matches)}`
499
+ + (predicate === undefined ? '' : `;条件:${predicate.clause}`);
500
+ return {
501
+ status: 'resolved',
502
+ reason,
503
+ confidence,
504
+ resolved: makeCandidate(record, confidence, matches),
505
+ ...(predicate === undefined ? {} : { predicate }),
506
+ };
507
+ }
508
+ /**
509
+ * Columns matched for a candidate listing, without the strictness of a final resolution.
510
+ * @param db - the open index.
511
+ * @param record - the candidate object.
512
+ * @param fields - requested fields.
513
+ * @param options - resolver tuning.
514
+ * @returns matched columns; empty when no fields were requested.
515
+ */
516
+ function candidateMatches(db, record, fields, options) {
517
+ if (fields.length === 0)
518
+ return [];
519
+ const columns = loadColumns(db, record.id, options.maxColumns + 1).slice(0, options.maxColumns);
520
+ return matchRequestedFields(columns, fields, options.aliases).matches;
521
+ }
522
+ /**
523
+ * The suffix a `not_found` reason uses to point at near-miss tables.
524
+ * @param db - the open index.
525
+ * @param words - what the caller typed.
526
+ * @returns a sentence fragment ending in a full stop.
527
+ */
528
+ function suggestionsFor(db, words) {
529
+ const suggestions = lookupObjectSuggestions(db, words);
530
+ return suggestions.length === 0
531
+ ? '。可先用 dbgraph objects 列出表名。'
532
+ : `。相近的表:${suggestions.map(displayName).join('、')}。`;
533
+ }
534
+ /**
535
+ * Suggest objects for a refusal reason.
536
+ * @param db - the open index.
537
+ * @param words - what the caller typed.
538
+ * @returns near matches, best first.
539
+ */
540
+ function lookupObjectSuggestions(db, words) {
541
+ // Kept separate from `suggestObjects` only so the reason's wording lives beside its call site.
542
+ const expression = searchMatchQuery(words);
543
+ if (expression !== '') {
544
+ const ids = ftsObjectIds(db, expression, 5);
545
+ if (ids.length > 0) {
546
+ const found = loadObjects(db, ids);
547
+ return ids.map(id => found.get(id)).filter((record) => record !== undefined);
548
+ }
549
+ }
550
+ return lookupObject(db, words.find(word => word !== '') ?? '').suggestions;
551
+ }
552
+ /**
553
+ * Resolve a request that names its table outright.
554
+ * @param db - the open index.
555
+ * @param engine - engine name from meta.
556
+ * @param request - the request.
557
+ * @param fields - requested fields.
558
+ * @param whereEntries - requested predicate entries.
559
+ * @param options - resolver tuning.
560
+ * @returns the resolution; confidence is 1 by contract once the name is unique.
561
+ */
562
+ function resolveByObjectName(db, engine, request, fields, whereEntries, options) {
563
+ const objectName = (request.object ?? '').trim();
564
+ const lookup = lookupObject(db, objectName);
565
+ if (lookup.matches.length > 1) {
566
+ const candidates = lookup.matches
567
+ .slice(0, options.maxCandidates)
568
+ .map(record => makeCandidate(record, 1, candidateMatches(db, record, fields, options)));
569
+ return {
570
+ status: 'ambiguous',
571
+ reason: `“${objectName}” 同时匹配 ${lookup.matches.length} 张表:`
572
+ + `${candidates.map(candidate => candidate.object).join('、')};请用 schema 限定名称。`,
573
+ confidence: 1,
574
+ candidates,
575
+ };
576
+ }
577
+ if (lookup.record === undefined) {
578
+ return {
579
+ status: 'not_found',
580
+ reason: `没有名为“${objectName}”的表${suggestionsFor(db, [objectName])}`,
581
+ confidence: 0,
582
+ };
583
+ }
584
+ return buildResolved(db, engine, lookup.record, 1, '按表名定位 ', fields, whereEntries, options);
585
+ }
586
+ /**
587
+ * Resolve a request whose table must be inferred from the entity words.
588
+ * @param db - the open index.
589
+ * @param engine - engine name from meta.
590
+ * @param request - the request.
591
+ * @param fields - requested fields.
592
+ * @param whereEntries - requested predicate entries.
593
+ * @param options - resolver tuning.
594
+ * @returns the resolution.
595
+ */
596
+ function resolveByEntity(db, engine, request, fields, whereEntries, options) {
597
+ const entity = (request.entity ?? '').trim();
598
+ const scored = scoreByEntity(db, entity, options);
599
+ if (scored.length === 0) {
600
+ return {
601
+ status: 'not_found',
602
+ reason: `没有匹配“${entity}”的表${suggestionsFor(db, [entity])}`,
603
+ confidence: 0,
604
+ };
605
+ }
606
+ const records = loadObjects(db, scored.map(entry => entry.objectId));
607
+ const ranked = scored
608
+ .map(entry => ({ entry, record: records.get(entry.objectId) }))
609
+ .filter((pair) => pair.record !== undefined)
610
+ .sort((left, right) => {
611
+ const byScore = right.entry.score - left.entry.score;
612
+ if (byScore !== 0)
613
+ return byScore;
614
+ const leftName = displayName(left.record);
615
+ const rightName = displayName(right.record);
616
+ return leftName < rightName ? -1 : leftName > rightName ? 1 : 0;
617
+ });
618
+ const top = ranked[0];
619
+ if (top === undefined) {
620
+ return {
621
+ status: 'not_found',
622
+ reason: `没有匹配“${entity}”的表${suggestionsFor(db, [entity])}`,
623
+ confidence: 0,
624
+ };
625
+ }
626
+ // An entity that folds to a table's full name is that table, however the token scoring ranked it:
627
+ // shared prefixes (`sys_user` / `sys_dept`) are exactly what the token score cannot separate.
628
+ const foldedEntity = normalizeIdentifier(entity);
629
+ if (foldedEntity !== '') {
630
+ const exact = ranked.filter(pair => normalizeIdentifier(displayName(pair.record)) === foldedEntity
631
+ || normalizeIdentifier(pair.record.name) === foldedEntity);
632
+ if (exact.length === 1) {
633
+ const only = exact[0];
634
+ return buildResolved(db, engine, only.record, 1, '表名精确匹配 ', fields, whereEntries, options);
635
+ }
636
+ }
637
+ const second = ranked[1];
638
+ const undecided = top.entry.score < options.minConfidence
639
+ || (second !== undefined && top.entry.score - second.entry.score < options.ambiguityGap);
640
+ if (undecided) {
641
+ const candidates = ranked
642
+ .slice(0, options.maxCandidates)
643
+ .map(pair => makeCandidate(pair.record, pair.entry.score, candidateMatches(db, pair.record, fields, options)));
644
+ return {
645
+ status: 'ambiguous',
646
+ reason: `“${entity}” 不是唯一匹配:`
647
+ + `${candidates.map(candidate => `${candidate.object}(${candidate.score.toFixed(2)})`).join('、')}`
648
+ + ';请用 object 指定表名,或补充 where 条件。',
649
+ confidence: top.entry.score,
650
+ candidates,
651
+ };
652
+ }
653
+ return buildResolved(db, engine, top.record, top.entry.score, `“${entity}” 命中 `, fields, whereEntries, options);
654
+ }
655
+ /**
656
+ * Resolve a request that gives only `where` conditions: the tables containing all of those columns.
657
+ * @param db - the open index.
658
+ * @param engine - engine name from meta.
659
+ * @param request - the request.
660
+ * @param fields - requested fields.
661
+ * @param whereEntries - requested predicate entries.
662
+ * @param options - resolver tuning.
663
+ * @returns the resolution.
664
+ */
665
+ function resolveByWhere(db, engine, fields, whereEntries, options) {
666
+ const keys = whereEntries.map(([key]) => key);
667
+ const rows = db.prepare('SELECT object_id AS object_id, name AS name FROM columns').all();
668
+ const byName = new Map();
669
+ for (const row of rows) {
670
+ const name = typeof row.name === 'string' ? row.name : undefined;
671
+ const objectId = typeof row.object_id === 'number' ? row.object_id : undefined;
672
+ if (name === undefined || objectId === undefined)
673
+ continue;
674
+ const folded = normalizeIdentifier(name);
675
+ if (folded === '')
676
+ continue;
677
+ const bucket = byName.get(folded);
678
+ if (bucket === undefined)
679
+ byName.set(folded, new Set([objectId]));
680
+ else
681
+ bucket.add(objectId);
682
+ }
683
+ const missing = keys.filter(key => !byName.has(normalizeIdentifier(key)));
684
+ if (missing.length > 0) {
685
+ return {
686
+ status: 'not_found',
687
+ reason: `没有任何表有字段${missing.map(key => `“${key}”`).join('、')};`
688
+ + '用 dbgraph columns <表> 查看某张表的可用字段。',
689
+ confidence: 0,
690
+ };
691
+ }
692
+ let candidates;
693
+ for (const key of keys) {
694
+ const bucket = byName.get(normalizeIdentifier(key));
695
+ if (candidates === undefined)
696
+ candidates = new Set(bucket);
697
+ else {
698
+ for (const id of candidates)
699
+ if (!bucket.has(id))
700
+ candidates.delete(id);
701
+ }
702
+ }
703
+ const objects = loadObjects(db, [...(candidates ?? [])]);
704
+ if (objects.size === 0) {
705
+ return {
706
+ status: 'not_found',
707
+ reason: `没有一张表同时包含字段${keys.map(key => `“${key}”`).join('、')}。`,
708
+ confidence: 0,
709
+ };
710
+ }
711
+ const ranked = [...objects.values()].sort((left, right) => {
712
+ const leftName = displayName(left);
713
+ const rightName = displayName(right);
714
+ return leftName < rightName ? -1 : leftName > rightName ? 1 : 0;
715
+ });
716
+ const makeWhereMatches = (record) => {
717
+ const columns = loadColumns(db, record.id, options.maxColumns + 1).slice(0, options.maxColumns);
718
+ const matches = [];
719
+ for (const key of keys) {
720
+ const column = findWhereColumn(columns, key);
721
+ if (column !== undefined)
722
+ matches.push(columnMatch(column, 'name', 1, key));
723
+ }
724
+ return matches;
725
+ };
726
+ if (ranked.length > 1) {
727
+ return {
728
+ status: 'ambiguous',
729
+ reason: `有 ${ranked.length} 张表同时包含${keys.map(key => `“${key}”`).join('、')}:`
730
+ + `${ranked.slice(0, options.maxCandidates).map(displayName).join('、')};请用 object 指定表名。`,
731
+ confidence: 0.7,
732
+ candidates: ranked
733
+ .slice(0, options.maxCandidates)
734
+ .map(record => makeCandidate(record, 0.7, makeWhereMatches(record))),
735
+ };
736
+ }
737
+ const only = ranked[0];
738
+ return buildResolved(db, engine, only, 0.7, `仅有表 `, fields, whereEntries, options);
739
+ }
740
+ /**
741
+ * Resolve a `find` request to an object, its columns, and a parameterized predicate — all offline.
742
+ * @param db - the open read-only index.
743
+ * @param engine - the connection's engine, read from the index's `meta`.
744
+ * @param request - the request to resolve.
745
+ * @param options - resolver tuning.
746
+ * @returns the resolution; the caller executes it only when the status is `resolved`.
747
+ */
748
+ export function resolveFind(db, engine, request, options) {
749
+ const objectName = (request.object ?? '').trim();
750
+ const entity = (request.entity ?? '').trim();
751
+ const fields = (request.fields ?? []).map(field => field.trim()).filter(field => field !== '');
752
+ const whereEntries = Object.entries(request.where ?? {});
753
+ if (objectName === '' && entity === '' && whereEntries.length === 0)
754
+ return missingPredicate();
755
+ if (objectName !== '') {
756
+ return resolveByObjectName(db, engine, request, fields, whereEntries, options);
757
+ }
758
+ if (entity !== '')
759
+ return resolveByEntity(db, engine, request, fields, whereEntries, options);
760
+ return resolveByWhere(db, engine, fields, whereEntries, options);
761
+ }
762
+ //# sourceMappingURL=resolve.js.map