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
21 changes: 21 additions & 0 deletions .changeset/27484-blank-search-fields-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/rest': patch
---

fix(metadata-protocol)!: a `searchFields` override naming no field (`$searchFields=`, `$searchFields=,`, `[]`) is refused 400, not read as absent, so a narrowed search no longer runs over the default search fields (#27484)

Clause-②: no (narrowing) — the data doors (`GET /data/:object`, `POST /data/:object/query`, `GET /data/:object/export`, and every other caller of `findData`) refuse a `searchFields` value that names no field, which they answered 200 over the default search set before. Nothing is widened.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a query value that names no field is refused where it was read as absent. The fix for a caller is to send a field name or leave the parameter out, which the refusal says; there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at the query door (`@objectstack/metadata-protocol`), served by every Data API door.

- **Before.** A blank or comma-only `$searchFields` (or `searchFields`), an empty array, or an empty object-form `search.fields` was read as no override, and the search scanned the object's whole default search set: `?$search=initech&$searchFields=` answered the rows `?$search=initech&$searchFields=industry` excludes, under a 200. The export route dropped a blank `searchFields` itself before the query ran.
- **Now.** `400 VALIDATION_ERROR`, naming the parameter the caller wrote (`Query parameter '$searchFields' names no field`), the envelope `$select` answers for a projection naming no field (#26644). No row is read. On the list and query doors this holds with or without a `search` term: `?$searchFields=` alone is refused too, as a non-empty override naming an unknown field already is.
- **Unchanged.** An absent `searchFields` searches the default set; a named one narrows it; a name outside the searchable set is still `400 INVALID_FIELD`. The engine (`find` / `count` called directly) still reads an empty override as absent. The export route still ignores `searchFields` sent without `search`.
- **Not yet narrowed.** The published declarations (`QueryTransportParamsSchema.$searchFields`, `EngineQueryOptionsSchema.searchFields`, `QuerySchema.searchFields`, `FullTextSearchSchema.fields`) still admit `''` and `[]`; the refusal is the door's. So on `POST /data/:object/query` the sibling `select: []` is the body schema's `400 VALIDATION_FAILED` while `searchFields: []` is this `400 VALIDATION_ERROR`. Narrowing the declarations is follow-up #27683.
- **Who sees it.** An API caller that sends `searchFields` with no field in it. The Console and the bundled view plugins send the parameter only when it holds at least one field.

**The remedy.** Leave the parameter out to search the default fields, or name the fields to search.
1 change: 1 addition & 0 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ corrupts something the earlier axes do not:
|:---|:---|
| `?search=alpha&searchFields=title` | scans only `title` |
| `?search=alpha&searchFields=no_such_field` | `400 INVALID_FIELD` |
| `?search=alpha&searchFields=` (or `=,`, or `[]` in a body) | `400 VALIDATION_ERROR` — names no field, the answer `$select=` gets; leave the parameter out to scan the default set |
| `?search=alpha&searchFields=amount` | `400 INVALID_FIELD` — real field, but not searchable |
| `?search=alpha&searchFields=project_id.name` | `400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) |
| `?search=alpha` on an object with no searchable field (two lookups and a `formula` title, say) | `400 INVALID_FIELD` — nothing to scan; declare a stored field searchable or mirror the title (see below) |
Expand Down
10 changes: 6 additions & 4 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -540,10 +540,12 @@ Only `query` and `fields` are implemented. The engine expands `search` into a dr
**narrow** the scan within the server-resolved searchable set — the object's declared
`searchableFields`, or a text-like auto-default when none are declared. Over the
REST/protocol ingress a name outside that set is `400 INVALID_FIELD`, with distinct
messages for a field that does not exist and a real field that is not searchable. The
engine used to drop unknown names and fall back to the full searchable set — a parameter
that exists to narrow a search, silently widening it. Internal callers reaching
`engine.find()` directly are unaffected.
messages for a field that does not exist and a real field that is not searchable. An
override that names no field at all (`?$searchFields=`, `?$searchFields=,`, an empty
array) is `400 VALIDATION_ERROR`, as `$select=` is; leave the parameter out to scan the
default set. The engine used to drop unknown names and fall back to the full searchable
set — a parameter that exists to narrow a search, silently widening it. Internal callers
reaching `engine.find()` directly are unaffected.
</Callout>

### Searching by a related record's title — mirror the value
Expand Down
26 changes: 21 additions & 5 deletions packages/metadata-protocol/src/protocol-query-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
*/

import type { ObjectStackProtocolImplementation } from './protocol.js';
import { FILTER_LOGICAL_KEYS, ODATA_SPELLING, QUERY_PARAM_NEAR_MISS, collectFilterReads, invalidSortError, isObjectOfDisabledPackage, isPlainRecord, nearMissKey, objectPackageDisabledError, suggestFieldName } from './protocol.js';
import { FILTER_LOGICAL_KEYS, ODATA_SPELLING, QUERY_PARAM_NEAR_MISS, collectFilterReads, invalidSortError, isObjectOfDisabledPackage, isPlainRecord, nearMissKey, objectPackageDisabledError, quotedQueryValue, suggestFieldName } from './protocol.js';
import { objectNotFoundError, relationConditionSites } from '@objectstack/core';
import { collectInternalWriteResponseFields } from './write-response-internal-fields.js';
import { AggregationFunction, DateGranularity, REFERENCE_VALUE_TYPES, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, apiExposureDenialReason, canServeApiOperation, classifyDottedFilterHead, effectiveOperationsArray, isFilterAST, isVirtualSearchField, parseFilterAST, referenceTargetOf, resolveEffectiveApiMethods, resolveSearchFieldResolution } from '@objectstack/spec/data';
Expand Down Expand Up @@ -874,10 +874,26 @@ export function assertSearchFieldsAreSearchable(
err.param = param;
throw err;
}
// An empty override is ABSENT — the engine falls through to the
// allowed set for it, which for a caller who named nothing is the
// answer they asked for, not a widened one.
if (names.length === 0) return;
// [#27484] …and an override naming NO field is refused, not read as
// absent. A blank or comma-only string, an empty array, or an empty
// object-form `search.fields` used to return here, and the engine then
// scanned the object's whole default set: a caller who wrote the
// parameter meant to narrow, so its silent widening to the defaults is
// the failure this gate exists for. Absent stays absent (the caller
// passes no override, and the door never calls this gate). The
// projection's envelope (`projectionFieldsValue`, #26644), so `$select`
// and `$searchFields` answer a value naming no field alike.
if (names.length === 0) {
const err: any = new Error(
`Query parameter '${param}' names no field; got ${quotedQueryValue(requested)}. The query was not `
+ "run: an empty search-field list was read as no narrowing, which scans the object's default "
+ 'search fields. Name at least one field, or leave the parameter out to search the default fields.',
);
err.status = 400;
err.code = 'VALIDATION_ERROR';
err.param = param;
throw err;
}
const gate = this.resolveQueryFields(object);
if (!gate) return;
// [#22646] A field declared `internal: true` is never a search target,
Expand Down
6 changes: 4 additions & 2 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4640,9 +4640,11 @@ function pageWindowValue(value: unknown, param: string): number {
/**
* A refused query value as the refusal quotes it: JSON, so a blank or
* whitespace-only value is visible, and bounded, so a long one cannot bury
* the sentence around it.
* the sentence around it. [#27484] Exported for the search-fields gate
* (`protocol-query-validation.ts`), which refuses an empty override in the
* projection's words.
*/
function quotedQueryValue(value: unknown): string {
export function quotedQueryValue(value: unknown): string {
// `String` for a number: `JSON.stringify(Infinity)` is `null`.
const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);
return shown.length > 64 ? `${shown.slice(0, 64)}…` : shown;
Expand Down
11 changes: 10 additions & 1 deletion packages/objectql/src/engine-search-empty-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,19 @@ describe('[#24284] the palette keeps its skip; the data door surfaces the refusa

it('findData over the junction answers the refusal, on the list and the query shapes', async () => {
const { protocol, reads } = await makeHarness();
for (const query of [{ search: 'zzzz-nothing' }, { search: 'Dune', searchFields: '' }, { search: { query: 'Dune' } }]) {
for (const query of [{ search: 'zzzz-nothing' }, { search: { query: 'Dune' } }]) {
const err: any = await (protocol as any).findData({ object: 'lib_loan', query }).then(() => null, (e: any) => e);
expect(err, JSON.stringify(query)).toMatchObject(REFUSAL);
}
// [#27484] Flipped on purpose (triage ruling 6500648325, option A): a
// blank override no longer reaches the engine as "absent", so the door
// refuses it first, in the projection's envelope. The point of this
// leg holds: a search over the junction never silently widens to every
// row; no `lib_loan` read is made on either refusal.
const blankOverride: any = await (protocol as any).findData({
object: 'lib_loan', query: { search: 'Dune', searchFields: '' },
}).then(() => null, (e: any) => e);
expect(blankOverride).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'searchFields' });
expect(reads.filter((r) => r.object === 'lib_loan')).toEqual([]);
// …and the control object and the blank term are untouched.
const book: any = await (protocol as any).findData({ object: 'lib_book', query: { search: 'Dune' } });
Expand Down
27 changes: 22 additions & 5 deletions packages/objectql/src/query-expression-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2271,12 +2271,29 @@ describe('#4254 — searchFields / groupBy / aggregations on the list path (real
})).rejects.toThrow(/scans this object's own columns/);
});

it('an empty override is ABSENT, not malformed', async () => {
for (const searchFields of ['', []]) {
expect(titles(await protocol.findData({
object: 'showcase_task', query: { search: 'a', searchFields },
}))).toEqual(['A', 'B']);
// [#27484] Flipped on purpose (triage ruling 6500648325, option A). This
// pin read an empty override as ABSENT; the door now refuses it in the
// projection's envelope (#26644), as `$select` refuses a projection naming
// no field. Read as absent, the search scanned the default set: a caller who
// wrote the parameter meant to narrow, and got the widest scan instead.
it('an override naming no field is refused, not read as absent', async () => {
const named = [
[{ searchFields: '' }, 'searchFields'],
[{ searchFields: ',' }, 'searchFields'],
[{ searchFields: ' , ' }, 'searchFields'],
[{ searchFields: [] }, 'searchFields'],
[{ $searchFields: '' }, '$searchFields'],
[{ search: { query: 'a', fields: [] } }, 'search'],
[{ search: { query: 'a', fields: '' } }, 'search'],
] as const;
for (const [query, param] of named) {
await expect(protocol.findData({
object: 'showcase_task', query: { search: 'a', ...query },
}), JSON.stringify(query)).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param });
}
// …and a search with no override at all is untouched: absent is absent.
expect(titles(await protocol.findData({ object: 'showcase_task', query: { search: 'a' } })))
.toEqual(['A', 'B']);
});

// ─────────────────────────────────────────────────────────────
Expand Down
12 changes: 9 additions & 3 deletions packages/rest/src/data-action-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,14 +1132,20 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
? q.search.trim()
: undefined;
// ADR-0061 override for which fields the term scans. Only meaningful
// alongside `search`; ignored on its own, exactly as in findData.
// alongside `search`; ignored on its own.
//
// [#27484] A value naming no field (`searchFields=`, `=,`) is
// kept as the empty list rather than read as absent, so
// `findData`'s search-fields gate refuses it exactly as on the
// list door. Read as absent, the export scanned the object's
// default search fields: a file of rows the caller's narrowing
// excluded.
let searchFields: string[] | undefined;
if (typeof q.searchFields === 'string' && q.searchFields.length > 0) {
if (typeof q.searchFields === 'string') {
searchFields = q.searchFields.split(',').map((s: string) => s.trim()).filter(Boolean);
} else if (Array.isArray(q.searchFields)) {
searchFields = q.searchFields.filter((s: any) => typeof s === 'string' && s.length > 0);
}
if (searchFields && searchFields.length === 0) searchFields = undefined;

let orderby: any = undefined;
if (typeof q.orderby === 'string' && q.orderby.length > 0) {
Expand Down
Loading
Loading