Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21880-search-companion-field-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

A field-narrowed `$search` no longer matches through the pinyin search companion of a field outside the search-field set (#21880).

Clause-②: no

- **What changed.** When the optional pinyin search companion is on (`OS_SEARCH_PINYIN_ENABLED`), the engine's search expansion (`expandSearchToFilter`) adds the companion clause only when every field the companion mirrors is inside the effective search-field set: the set `resolveSearchFields` computes, after any `$searchFields` narrowing. The mirrored fields are read from `resolveSearchCompanionSources`, the same function the companion is provisioned and filled from.
- **What stays the same.** A search with no narrowing keeps the clause whenever the display/name field is in the object's searchable set, so pinyin recall there is unchanged. A CJK term still skips the clause. Deployments with the companion off see no change.
- **Who notices.** A search narrowed to fields that leave out the display/name field, by a `$searchFields` override, by the narrowing global search applies to the fields a caller may query, or by a declared `searchableFields` that omits it, no longer matches through that field's pinyin form.
117 changes: 117 additions & 0 deletions packages/objectql/src/search-companion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,123 @@ describe('expandSearchToFilter with companion column (query-time, additive)', ()
});
});

/**
* [#21880] The companion clause follows the effective search-field set.
*
* The companion is a normalized copy of its source fields, so it may join a
* search only when every field it mirrors is inside the set
* `resolveSearchFields` computed. A field-narrowed search that leaves a
* mirrored field out gets no companion clause; a search with no narrowing
* keeps it, so recall is unchanged there.
*/
describe('[#21880] the companion clause follows the effective search-field set', () => {
// `crm_contact`: the companion mirrors `name` (the derived display field).
const fields = provisionSearchCompanion(contact()).fields as any;
const companionClause = (term: string) => ({ [SEARCH_COMPANION_FIELD]: { $contains: term } });

it('premise: the companion is provisioned and mirrors exactly `name`', () => {
expect(fields[SEARCH_COMPANION_FIELD]).toBeDefined();
expect(resolveSearchCompanionSources({ name: 'crm_contact', fields })).toEqual(['name']);
});

describe('(a) a field-narrowed search that leaves the mirrored field out gets no companion clause', () => {
it('a `$searchFields` override without the mirrored field', () => {
expect(expandSearchToFilter('zhangwei', { fields, requestedFields: ['email'] })).toEqual({
$or: [{ email: { $icontains: 'zhangwei' } }],
});
// The comma-separated spelling a URL query parameter arrives as.
expect(expandSearchToFilter('zhangwei', { fields, requestedFields: 'email,notes' })).toEqual({
$or: [{ email: { $icontains: 'zhangwei' } }, { notes: { $icontains: 'zhangwei' } }],
});
});

it('the narrowing carried on the search term itself (`{ query, fields }`)', () => {
expect(expandSearchToFilter({ query: 'zhangwei', fields: ['notes'] }, { fields })).toEqual({
$or: [{ notes: { $icontains: 'zhangwei' } }],
});
});

it('a declared `searchableFields` set without the mirrored field', () => {
expect(expandSearchToFilter('zhangwei', { fields, searchableFields: ['email'] })).toEqual({
$or: [{ email: { $icontains: 'zhangwei' } }],
});
});

it('every term of a multi-term search', () => {
const filter = expandSearchToFilter('zhang wei', { fields, requestedFields: ['email'] });
expect(filter).toEqual({
$and: [
{ $or: [{ email: { $icontains: 'zhang' } }] },
{ $or: [{ email: { $icontains: 'wei' } }] },
],
});
});

it('reads the REAL source: an explicit `nameField` pointer moves what the companion mirrors', () => {
// `crm_ticket` names `subject` as its title, so the companion mirrors
// `subject` — not `name`, although a `name` field exists.
const ticket = provisionSearchCompanion({
name: 'crm_ticket',
nameField: 'subject',
fields: { subject: { type: 'text' }, name: { type: 'text' } },
});
const ticketFields = ticket.fields as any;
expect(resolveSearchCompanionSources(ticket)).toEqual(['subject']);

const withoutSubject = expandSearchToFilter('zhangwei', {
fields: ticketFields, displayField: 'subject', requestedFields: ['name'],
});
expect(withoutSubject).toEqual({ $or: [{ name: { $icontains: 'zhangwei' } }] });

const withSubject = expandSearchToFilter('zhangwei', {
fields: ticketFields, displayField: 'subject', requestedFields: ['subject'],
});
expect(withSubject.$or).toContainEqual(companionClause('zhangwei'));
});
});

describe('(b) a search with no narrowing keeps the companion clause (recall unchanged)', () => {
it('the auto-default set, which leads with the mirrored field', () => {
expect(expandSearchToFilter('ZhangWei', { fields })).toEqual({
$or: [
{ name: { $icontains: 'ZhangWei' } },
{ email: { $icontains: 'ZhangWei' } },
{ notes: { $icontains: 'ZhangWei' } },
companionClause('zhangwei'),
],
});
});

it('a narrowed set that still holds the mirrored field', () => {
expect(expandSearchToFilter('zw', { fields, requestedFields: ['name'] })).toEqual({
$or: [{ name: { $icontains: 'zw' } }, companionClause('zw')],
});
});

it('the gate reads the RESOLVED set: a request naming no allowed field falls back to the full set', () => {
// `resolveSearchFields` drops unknown names and falls back to the
// allowed set when none survives — so the effective set holds `name`.
const filter = expandSearchToFilter('zw', { fields, requestedFields: ['no_such_field'] });
expect(filter.$or).toContainEqual(companionClause('zw'));
});
});

describe('(c) a CJK term still skips the companion clause, as before', () => {
it('with and without narrowing', () => {
expect(expandSearchToFilter('张伟', { fields })).toEqual({
$or: [
{ name: { $icontains: '张伟' } },
{ email: { $icontains: '张伟' } },
{ notes: { $icontains: '张伟' } },
],
});
expect(expandSearchToFilter('张伟', { fields, requestedFields: ['name'] })).toEqual({
$or: [{ name: { $icontains: '张伟' } }],
});
});
});
});

describe('containsCJK / isCompanionMatchableTerm', () => {
it('detects Han characters', () => {
expect(containsCJK('张伟')).toBe(true);
Expand Down
60 changes: 57 additions & 3 deletions packages/objectql/src/search-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
* `resolveSearchFields` still returns only source fields (the companion is
* invisible to `$searchFields` overrides and to clients).
*
* [#21880] …and bounded by the same set. The companion is a normalized copy of
* named source fields, so its clause is a match on THOSE fields. It joins a
* search only when every field it mirrors is inside the effective search-field
* set `resolveSearchFields` computed — after any `$searchFields` narrowing — and
* a set that leaves a mirrored field out leaves the companion out with it. See
* {@link companionWithinSearchFields}.
*
* [#21009] A field the object declares MULTI-VALUED (`isMultiValueField`: a
* `tags` / `multiselect` / `checkboxes` field, or a `select` / `lookup` /
* `user` / … declared `multiple: true`) is matched by MEMBERSHIP, `$contains`,
Expand All @@ -68,7 +75,12 @@ import {
type SearchFieldMeta,
type SearchFieldResolutionOptions,
} from '@objectstack/spec/data';
import { SEARCH_COMPANION_FIELD, isCompanionMatchableTerm } from './search-companion.js';
import {
SEARCH_COMPANION_FIELD,
isCompanionMatchableTerm,
resolveSearchCompanionSources,
type CompanionObjectMeta,
} from './search-companion.js';

export {
resolveSearchFields,
Expand Down Expand Up @@ -147,6 +159,44 @@ function fieldClausesForTerm(field: string, term: string, meta: SearchFieldMeta)
return [{ [field]: { $icontains: term } }];
}

/**
* [#21880] May the `__search` companion clause join a search over
* `searchFields`? Only when every source field the companion mirrors is in
* that set.
*
* The mirrored fields are read from {@link resolveSearchCompanionSources} —
* the one function the registry's provisioning seam and plugin-pinyin-search's
* populate hook already derive the companion from — over the same `fields` and
* the same display-field pointer the engine handed in. So the answer is the
* companion's real source, never a second guess at it.
*
* ⛔ The gate is `searchFields` and nothing else: the set `resolveSearchFields`
* already computed, with the declared/auto-default precedence and any
* `$searchFields` narrowing applied. No second eligibility rule for the
* companion is consulted here — whatever a caller's narrowing removed from the
* source columns, it removes from their normalized copy too.
*
* The companion is ONE column holding the normalized form of its sources, so
* the test is "every source is in the set", never "some source is": a clause
* over the shared column matches through every field it mirrors at once.
* A search whose set holds every mirrored field — any search with no
* narrowing, whenever the display/name field is in the object's searchable
* set — keeps the clause, so recall there is unchanged.
*
* An empty source list passes vacuously. The registry never provisions a
* companion without a source (`provisionSearchCompanion` returns early on an
* empty list), so that case is only an author-declared `__search` column —
* an ordinary field the platform does not fill — and it keeps today's answer.
*/
function companionWithinSearchFields(searchFields: readonly string[], opts: ExpandSearchOptions): boolean {
const sources = resolveSearchCompanionSources({
nameField: opts.displayField,
fields: opts.fields as CompanionObjectMeta['fields'],
});
const inSet = new Set(searchFields);
return sources.every((f) => inSet.has(f));
}

/**
* Expand a `$search` term into a `{ $or: [...] }` (single term) or
* `{ $and: [{ $or: [...] }, ...] }` (multi-term) filter. Returns `null` when
Expand Down Expand Up @@ -177,10 +227,14 @@ export function expandSearchToFilter(raw: unknown, opts: ExpandSearchOptions): a
// different mechanism from the source-column clauses in
// `fieldClausesForTerm`, which compare against raw stored text and therefore
// need `$icontains`. Do not "align" the two.
const hasCompanion = !!opts.fields[SEARCH_COMPANION_FIELD];
//
// [#21880] …and only when the companion mirrors no field outside
// `searchFields` — see `companionWithinSearchFields`.
const withCompanion = !!opts.fields[SEARCH_COMPANION_FIELD]
&& companionWithinSearchFields(searchFields, opts);
const andClauses = terms.map((term) => {
const clauses = searchFields.flatMap((f) => fieldClausesForTerm(f, term, opts.fields[f] || {}));
if (hasCompanion && isCompanionMatchableTerm(term)) {
if (withCompanion && isCompanionMatchableTerm(term)) {
clauses.push({ [SEARCH_COMPANION_FIELD]: { $contains: term.toLowerCase() } });
}
return { $or: clauses };
Expand Down
1 change: 1 addition & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@objectstack/plugin-audit": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-email": "workspace:*",
"@objectstack/plugin-pinyin-search": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
Expand Down
Loading
Loading