You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(search): make the mirror field the one prescription for related-title search (#6670)
`$search` scans the queried object's own columns; a dotted path
(`project_id.name`) in `searchableFields` / `$searchFields` is refused by the
#4254 ingress gate and by `validate-searchable-fields`. Both errors already
pointed at "mirror the related title onto a local field", but nothing in the
authoring corpus said it, so every project invented its own convention.
Writes ONE prescription, told the same way in every place authors and AI look:
to search by a related record's title, mirror that title into a STORED text
field on this object and declare that field searchable.
- skills/objectstack-data — new `## Search Fields (searchableFields)` section
with the worked `task` → `project_name` example, the maintenance hooks, and
both error messages quoted verbatim so either is greppable back to the fix.
- skills/objectstack-query — mirror-field subsection under Full-Text Search
(query-side shape + expansion), plus a row in the cross-object tool table.
- content/docs/data-modeling/schema-design — canonical long form.
- content/docs/data-modeling/queries, protocol/objectql/query-syntax,
api/data-api, ui/views — short form pointing at the canonical section.
Records one correctness point the existing hints get loose: the mirror must be
a STORED field. A `formula` field is virtual (no driver materializes a column),
so a `$contains` predicate against one has nothing to scan, and CEL only reads
`record.<own field>` so it cannot fetch the related title anyway — yet
`searchableFields` admits any declared field, so the mistake clears both lint
and the ingress gate and then silently never matches.
Cross-object search paths stay rejected by design; routes 2/3 are not written
in as roadmap.
Fixes#4840
Co-authored-by: os-zhuang <steve@objectstack.ai>
|`?search=alpha&searchFields=amount`|`400 INVALID_FIELD` — real field, but not searchable |
131
+
|`?search=alpha&searchFields=project_id.name`|`400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) |
131
132
|`groupBy: ["status"]`| one bucket per status value |
`?search=apollo` now expands to `name $contains 'apollo' OR project_name
133
+
$contains 'apollo'` — one single-table scan, on every driver, with no traversal.
134
+
If the object declares no `searchableFields` at all, a `text` mirror is picked up
135
+
by the auto-default anyway; declare the set explicitly when you want to pin it.
136
+
137
+
<Callouttype="warn">
138
+
**The mirror must be a stored field — a `formula` field does not work.** A
139
+
`formula` field is *virtual*: no driver materializes a column for it, so a
140
+
`$contains` predicate against one has nothing to scan (the SQL driver would emit
141
+
a `WHERE` over a column that does not exist). A CEL formula also only reads this
142
+
record's own fields (`record.<field>`), so it cannot fetch the related title in
143
+
the first place. Nothing catches the mistake for you — `searchableFields` admits
144
+
any field the object declares, so a formula entry passes both lint and the
145
+
ingress gate and then just never matches.
146
+
</Callout>
147
+
148
+
**Keeping the mirror fresh.** A mirror is denormalized data, only as current as
149
+
whatever maintains it. Two write paths have to be covered:
150
+
151
+
| When | What maintains the mirror |
152
+
|:-----|:--------------------------|
153
+
| A task is created, or re-pointed at another project |`beforeInsert` / `beforeUpdate` hook on `task` — read the parent's `name` for the incoming `project_id` and stamp `project_name`|
154
+
| A project is renamed |`afterUpdate` hook on `project` — re-stamp `project_name` on that project's tasks |
155
+
156
+
Rows written by a path that bypasses hooks (bulk import, direct SQL) need a
157
+
one-off backfill. See [Hooks](/docs/automation/hooks) for the hook shapes.
158
+
159
+
**The errors you get if you try the dotted path.** Both the lint and the runtime
160
+
send you to the same fix, so either message is greppable back to this section.
161
+
162
+
`os validate` reports `searchable-field-unknown`:
163
+
164
+
```text
165
+
searchableFields entry "project_id.name" is not a field on object "task". The
166
+
declaration is stale: searching it can never match, and the engine silently
167
+
drops it — leaving a narrower search than declared, or the auto-default set once
168
+
every entry is dropped.
169
+
170
+
hint: 'search' scans this object's own columns, so a related record's column
171
+
cannot be a search target — expand the relation and search the related object,
172
+
or copy the value onto a formula field here. Clients echo this declaration
173
+
verbatim as the '$searchFields' override, so a stale entry becomes a 400
174
+
INVALID_FIELD on list search (#4254), not just a quietly narrowed one.
175
+
```
176
+
177
+
(That hint's "text/formula" family of wording is loose — only the **stored**
178
+
half works; see the callout above.)
179
+
180
+
A request that sends the dotted path is `400 INVALID_FIELD`:
181
+
182
+
```text
183
+
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
184
+
columns 'search' scans, so a name the object does not declare cannot narrow
185
+
anything — and the engine used to drop it and scan the default columns instead,
186
+
answering a NARROWER search with a WIDER one. 'search' scans this object's own
187
+
columns; a related record's column cannot be a search target.
188
+
```
189
+
190
+
If the dotted path is in the object's own `searchableFields` (so clients echo it
191
+
back verbatim), the same 400 arrives under its stale-declaration wording
192
+
instead: `Field 'project_id.name' on object 'task' is declared in
|`searchableFields`|`string[]`|optional|Fieldsthetoolbarsearchscans — **narrows**theobject's set, never widens it (ADR-0061). Entries must be the object's**own**columns: alookup (`project_id`) oradottedpath (`project_id.name`) isrefused, andeverytoolbarsearchonthelistthenreturns`400 INVALID_FIELD` (#4254). Tosearchbyarelatedrecord's title, [mirror it into a stored field](/docs/data-modeling/schema-design#searching-by-a-related-records-title--mirror-the-value) on the object and list that|
`?search=apollo` expands to `name $contains 'apollo' OR project_name $contains
137
+
'apollo'` — one single-table scan, every driver, no traversal. (A `text` mirror
138
+
also lands in the auto-default set when the object declares no
139
+
`searchableFields`.)
140
+
141
+
❌ **Never mirror onto a `formula` field.** A formula field is *virtual* — no
142
+
driver materializes a column for it, so a `$contains` predicate against one has
143
+
nothing to scan (the SQL driver would emit a `WHERE` over a column that does not
144
+
exist). CEL also only reads this record's own fields (`record.<field>`), so a
145
+
formula cannot fetch the related title in the first place. Nothing rejects the
146
+
mistake: `searchableFields` admits any field the object declares, so a formula
147
+
entry clears both lint and the ingress gate and then never matches.
148
+
149
+
**Mirror maintenance is the trade-off** — a mirror is denormalized data, only as
150
+
fresh as whatever writes it. Cover both write paths:
151
+
152
+
| When | What maintains the mirror |
153
+
|:-----|:--------------------------|
154
+
| A task is created, or re-pointed at another project |`beforeInsert` / `beforeUpdate` hook on `task` — read the parent's `name` for the incoming `project_id`, stamp `project_name`|
155
+
| A project is renamed |`afterUpdate` hook on `project` — re-stamp `project_name` on that project's tasks |
156
+
157
+
Rows written by a path that bypasses hooks (bulk import, direct SQL) need a
158
+
one-off backfill. See [Lifecycle Hooks](./rules/hooks.md).
159
+
160
+
**The errors an author sees for the dotted path** (grep either back to here).
161
+
`os validate` → `searchable-field-unknown`:
162
+
163
+
```text
164
+
searchableFields entry "project_id.name" is not a field on object "task". The
165
+
declaration is stale: searching it can never match, and the engine silently
166
+
drops it — leaving a narrower search than declared, or the auto-default set once
167
+
every entry is dropped.
168
+
169
+
hint: 'search' scans this object's own columns, so a related record's column
170
+
cannot be a search target — expand the relation and search the related object,
171
+
or copy the value onto a formula field here. Clients echo this declaration
172
+
verbatim as the '$searchFields' override, so a stale entry becomes a 400
173
+
INVALID_FIELD on list search (#4254), not just a quietly narrowed one.
174
+
```
175
+
176
+
(The hint's "formula field" wording is loose — only a **stored** mirror works.)
177
+
178
+
A request carrying the dotted path is `400 INVALID_FIELD`:
179
+
180
+
```text
181
+
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
182
+
columns 'search' scans, so a name the object does not declare cannot narrow
183
+
anything — and the engine used to drop it and scan the default columns instead,
184
+
answering a NARROWER search with a WIDER one. 'search' scans this object's own
185
+
columns; a related record's column cannot be a search target.
186
+
```
187
+
188
+
Cross-object search paths are rejected by design, not pending. Do not invent a
189
+
per-project convention for this — the mirror field is the answer.
190
+
191
+
---
192
+
103
193
## Field Groups (MVP)
104
194
105
195
Organize fields into logical groups (e.g., "Contact Information", "Billing",
|**Keyword-search by a related record's title**|**Mirror the title into a stored field on this object and search that** — `search` never traverses (see **Full-Text Search** above) |
538
589
| Simple parent→child navigation |`expand`|
539
590
| Paginate/sort a parent's related records | Query the related object directly |
540
591
| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code (`joins` was removed in #4286 — see above) |
0 commit comments