docs(176): fix plan operator model — split fulltext matches/notMatches from text contains (Task 3 review Critical)
This commit is contained in:
@@ -324,7 +324,7 @@ git -c core.hooksPath=/dev/null commit -m "feat(176): GET /api/v1/search/fields
|
||||
- Produces: `FieldType = 'text'|'fulltext'|'number'|'date'|'enum'`; `Operator`; `Rule`; `Group`; `Match`; `isGroup(node)`; `compile(group: Group): string`.
|
||||
- The canonical compiled forms (parser in Task 4 is the exact inverse):
|
||||
- text `is` → `field:"v"` · `isNot` → `NOT field:"v"` · `contains` → `field:*v*` · `startsWith` → `field:v*`
|
||||
- fulltext `contains` → `field:"v"` · `notContains` → `NOT field:"v"`
|
||||
- fulltext `matches` → `field:"v"` · `notMatches` → `NOT field:"v"` (distinct operator names from text's `contains`, so `compileRule` maps each operator to exactly one canonical form; parse disambiguates the shared `field:"v"` shape by field type: text/enum→`is`, fulltext→`matches`)
|
||||
- enum `is`/`isNot` → `field:"v"` / `NOT field:"v"`
|
||||
- number `eq` → `field:v` · `gt` → `field:{v TO *}` · `lt` → `field:{* TO v}` · `between` → `field:[v TO v2]`
|
||||
- date `before` → `field:{* TO v}` · `after` → `field:{v TO *}` · `between` → `field:[v TO v2]`
|
||||
@@ -337,9 +337,10 @@ export type FieldType = 'text' | 'fulltext' | 'number' | 'date' | 'enum';
|
||||
export type Match = 'all' | 'any';
|
||||
|
||||
export type Operator =
|
||||
| 'is' | 'isNot' | 'contains' | 'startsWith' | 'notContains'
|
||||
| 'eq' | 'gt' | 'lt' | 'between'
|
||||
| 'before' | 'after';
|
||||
| 'is' | 'isNot' | 'contains' | 'startsWith' // text
|
||||
| 'matches' | 'notMatches' // fulltext
|
||||
| 'eq' | 'gt' | 'lt' | 'between' // number
|
||||
| 'before' | 'after'; // date
|
||||
|
||||
export interface Rule {
|
||||
field: string;
|
||||
@@ -360,7 +361,7 @@ export function isGroup(node: Rule | Group): node is Group {
|
||||
// Which fields each catalog type exposes as operators (used by the UI and tests).
|
||||
export const OPERATORS_BY_TYPE: Record<FieldType, Operator[]> = {
|
||||
text: ['is', 'isNot', 'contains', 'startsWith'],
|
||||
fulltext: ['contains', 'notContains'],
|
||||
fulltext: ['matches', 'notMatches'],
|
||||
enum: ['is', 'isNot'],
|
||||
number: ['eq', 'gt', 'lt', 'between'],
|
||||
date: ['before', 'after', 'between']
|
||||
@@ -387,11 +388,16 @@ describe('compile', () => {
|
||||
expect(compile(g)).toBe('NOT genre:"Horror"');
|
||||
});
|
||||
|
||||
it('wildcards contains and startsWith', () => {
|
||||
it('wildcards text contains and startsWith', () => {
|
||||
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] })).toBe('title:*night*');
|
||||
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] })).toBe('title:The*');
|
||||
});
|
||||
|
||||
it('quotes fulltext matches / notMatches (distinct from text contains)', () => {
|
||||
expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car chase' }] })).toBe('plot:"car chase"');
|
||||
expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] })).toBe('NOT plot:"car"');
|
||||
});
|
||||
|
||||
it('emits numeric ranges', () => {
|
||||
expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] })).toBe('minutes:{30 TO *}');
|
||||
expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'lt', value: '90' }] })).toBe('minutes:{* TO 90}');
|
||||
@@ -445,15 +451,15 @@ function compileRule(rule: Rule): string {
|
||||
const v = rule.value;
|
||||
switch (rule.operator) {
|
||||
case 'is':
|
||||
case 'matches': // fulltext: same quoted form as text `is`, disambiguated by field type on parse
|
||||
return `${f}:${quote(v)}`;
|
||||
case 'isNot':
|
||||
case 'notMatches':
|
||||
return `NOT ${f}:${quote(v)}`;
|
||||
case 'contains':
|
||||
return `${f}:*${escapeWild(v)}*`;
|
||||
case 'startsWith':
|
||||
return `${f}:${escapeWild(v)}*`;
|
||||
case 'notContains':
|
||||
return `NOT ${f}:${quote(v)}`;
|
||||
case 'eq':
|
||||
return `${f}:${v}`;
|
||||
case 'gt':
|
||||
@@ -524,9 +530,13 @@ describe('parse', () => {
|
||||
expect(parse('genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] });
|
||||
});
|
||||
|
||||
it('parses NOT as isNot / notContains by field type', () => {
|
||||
it('parses NOT as isNot / notMatches by field type', () => {
|
||||
expect(parse('NOT genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] });
|
||||
expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notContains', value: 'car' }] });
|
||||
expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] });
|
||||
});
|
||||
|
||||
it('parses a quoted fulltext atom as matches', () => {
|
||||
expect(parse('plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car' }] });
|
||||
});
|
||||
|
||||
it('parses wildcard forms', () => {
|
||||
@@ -634,10 +644,10 @@ function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule |
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quoted phrase → is / isNot / contains / notContains by type
|
||||
// Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type
|
||||
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
||||
const v = unquote(raw.slice(1, -1));
|
||||
if (type === 'fulltext') return mk(field, negate ? 'notContains' : 'contains', v);
|
||||
if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v);
|
||||
if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v);
|
||||
return null;
|
||||
}
|
||||
@@ -736,7 +746,7 @@ const BY_TYPE: Record<FieldType, string[]> = {
|
||||
};
|
||||
const OPS: Record<FieldType, Operator[]> = {
|
||||
text: ['is', 'isNot', 'contains', 'startsWith'],
|
||||
fulltext: ['contains', 'notContains'],
|
||||
fulltext: ['matches', 'notMatches'],
|
||||
enum: ['is', 'isNot'],
|
||||
number: ['eq', 'gt', 'lt', 'between'],
|
||||
date: ['before', 'after', 'between']
|
||||
@@ -922,7 +932,8 @@ import type { SearchField } from '../../api/search';
|
||||
import { isGroup, OPERATORS_BY_TYPE, type FieldType, type Group, type Operator, type Rule } from './types';
|
||||
|
||||
const OP_LABEL: Record<Operator, string> = {
|
||||
is: 'is', isNot: 'is not', contains: 'contains', startsWith: 'starts with', notContains: 'does not contain',
|
||||
is: 'is', isNot: 'is not', contains: 'contains', startsWith: 'starts with',
|
||||
matches: 'contains', notMatches: 'does not contain',
|
||||
eq: '=', gt: '>', lt: '<', between: 'between', before: 'before', after: 'after'
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user