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
31 changes: 31 additions & 0 deletions .changeset/lint-searchable-fields-type-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/lint": minor
---

feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830)

视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而
REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定
该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400
(`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查
字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。

新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级
narrowing(对象内建 `listViews`、`defineView` 的 `list`/`listViews`、react 页面的
`<ListView searchableFields>`)按**运行时同一个函数**(`resolveSearchFieldResolution`,
非复制的类型清单,杜绝再度漂移)判定 declared = enforced:

- 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等
auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本
对象 text/formula 字段」的处方;
- 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、
不能放宽,ADR-0061);
- 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按
存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据
(ADR-0072 D1);
- 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报
不可误报)。

内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包
barrel)签名有变:索引值从 `Set<string> | null` 变为 `ObjectSearchTarget | null`,并新增
可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,12 @@ export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-t
export {
validateSearchableFields,
SEARCHABLE_FIELD_UNKNOWN,
SEARCHABLE_FIELD_UNSEARCHABLE,
} from './validate-searchable-fields.js';
export type {
SearchableFieldFinding,
SearchableFieldSeverity,
SearchableFieldRole,
} from './validate-searchable-fields.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
Expand Down
22 changes: 20 additions & 2 deletions packages/lint/src/validate-react-page-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
REACT_BLOCK_NEEDS_RECORD_CONTEXT,
type ReactPropFinding as PropFinding,
} from './validate-react-page-props.js';
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
import {
SEARCHABLE_FIELD_UNKNOWN,
SEARCHABLE_FIELD_UNSEARCHABLE,
} from './validate-searchable-fields.js';
import { PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';

const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] });
Expand Down Expand Up @@ -303,11 +306,26 @@ describe('validateReactPageProps — <ListView> searchableFields (#4329)', () =>

it('accepts registry-injected system columns absent from authored fields', () => {
const f = validateReactPageProps(
listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at', 'owner_id']}`)),
listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at']}`)),
);
expect(f).toEqual([]);
});

it('flags an authored lookup — the runtime refuses the echoed override (#4830)', () => {
// <ListView searchableFields> is echoed verbatim as `$searchFields`, and
// the #4254 ingress gate rejects a lookup with 400 INVALID_FIELD — the
// whole toolbar search, for every role. Same judgment as the metadata
// list-view surface, by the shared core.
const f = validateReactPageProps(
listPage(list(`objectName="crm_account" searchableFields={['name', 'owner_id']}`)),
);
expect(f).toHaveLength(1);
expect(f[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(f[0].severity).toBe('error');
expect(f[0].path).toBe('pages[0].source › searchableFields[1]');
expect(f[0].message).toContain('400 INVALID_FIELD');
});

it('flags a dotted path — search cannot resolve the traversal', () => {
const f = validateReactPageProps(
listPage(list(`objectName="crm_account" searchableFields={['owner_id.name']}`)),
Expand Down
13 changes: 8 additions & 5 deletions packages/lint/src/validate-react-page-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,11 +869,14 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
checkObjectChart({ values, where, path }, objectFields, findings);
}
// <ListView searchableFields> names fields on the bound object — the
// react-surface twin of `searchable-field-unknown` (#4329). It runs
// the metadata rule's own core, so the skips (cross-package object,
// no authored field map, system columns) and the dotted-path
// strictness match by construction. A non-static value — either
// attribute — bails inside the checker: unresolvable is not wrong.
// react-surface twin of `searchable-field-unknown` (#4329) and, as a
// view-level narrowing echoed to the runtime as `$searchFields`, of
// `searchable-field-unsearchable` too (#4830, the checker's default
// role). It runs the metadata rule's own core, so the skips
// (cross-package object, no authored field map, system columns) and
// the dotted-path strictness match by construction. A non-static
// value — either attribute — bails inside the checker: unresolvable
// is not wrong.
if (tag === 'ListView' && !hasSpread) {
findings.push(
...checkSearchableFieldList(
Expand Down
168 changes: 168 additions & 0 deletions packages/lint/src/validate-searchable-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
import {
validateSearchableFields,
SEARCHABLE_FIELD_UNKNOWN,
SEARCHABLE_FIELD_UNSEARCHABLE,
} from './validate-searchable-fields.js';

/**
Expand Down Expand Up @@ -287,6 +288,173 @@ describe('validateSearchableFields — list views that narrow the set', () => {
expect(findings).toEqual([]);
});

it('flags a lookup entry the runtime would refuse — the #4830 defect', () => {
// The issue's repro verbatim: `searchableFields: ['name', '<lookup>']` on a
// view, validate all green, first keystroke in the toolbar search → the
// whole query 400s (INVALID_FIELD) for every role. The runtime judgment
// is `resolveSearchFieldResolution` (@objectstack/spec/data); this rule
// consults the same function, so declared = enforced.
const findings = validateSearchableFields({
objects: [
{
name: 'ehr_task',
fields: {
name: { type: 'text' },
project_id: { type: 'lookup', reference: 'ehr_project' },
},
listViews: { all: { type: 'grid', searchableFields: ['name', 'project_id'] } },
},
],
});

expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[1]');
expect(findings[0].message).toContain("type 'lookup'");
expect(findings[0].message).toContain('400 INVALID_FIELD');
// The lookup-specific prescription: search cannot cross objects, so the
// related record's title must be mirrored onto a local text/formula field.
expect(findings[0].hint).toContain('mirror');
});

it('flags a real field outside the object\'s declared searchableFields', () => {
// Runtime parity, declared branch: the object declares the canonical set,
// and the #4254 gate refuses a `$searchFields` entry outside it even when
// the field exists and is text-like.
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: {
name: { type: 'text' },
billing_email: { type: 'email' },
notes: { type: 'textarea' },
},
searchableFields: ['name', 'billing_email'],
listViews: { all: { type: 'grid', searchableFields: ['notes'] } },
},
],
});

expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(findings[0].message).toContain('name, billing_email');
expect(findings[0].hint).toContain('crm_account.searchableFields');
});

it('passes a view entry of odd type once the object declares it searchable', () => {
// The runtime's declared branch filters by EXISTENCE, never by type: a
// json/lookup column declared on the OBJECT is honored by the engine and
// admitted by the gate, so the view echoing it must stay green — flagging
// it would reject metadata the runtime accepts.
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: { name: { type: 'text' }, payload: { type: 'json' } },
searchableFields: ['name', 'payload'],
listViews: { all: { type: 'grid', searchableFields: ['payload'] } },
},
],
});

expect(findings).toEqual([]);
});

it('flags a hidden field in a view narrowing (auto-default excludes it)', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: { name: { type: 'text' }, secret_note: { type: 'text', hidden: true } },
listViews: { all: { type: 'grid', searchableFields: ['secret_note'] } },
},
],
});

expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(findings[0].message).toContain('hidden');
});

it('checks defineView list and named listViews the same way', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } },
},
],
views: [
{
objectName: 'crm_account',
list: { type: 'grid', searchableFields: ['owner_ref'] },
listViews: { active: { type: 'grid', searchableFields: ['owner_ref'] } },
},
],
});

expect(findings.map((f) => [f.rule, f.path])).toEqual([
[SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].list.searchableFields[0]'],
[SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].listViews.active.searchableFields[0]'],
]);
});

it('keeps runtime parity when the object declares system columns searchable', () => {
// The runtime resolves the declared branch against the REGISTRY map, so
// `searchableFields: ['created_at']` is a non-empty declared set there —
// NOT a fall-through to the auto-default. A view entry outside that set
// must be flagged the way the gate refuses it, even though `created_at`
// is invisible to the authored field map.
const findings = validateSearchableFields({
objects: [
{
name: 'audit_log',
fields: { name: { type: 'text' }, detail: { type: 'textarea' } },
searchableFields: ['created_at'],
listViews: { all: { type: 'grid', searchableFields: ['detail'] } },
},
],
});

expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
expect(findings[0].message).toContain('created_at');
});

it('leaves a system column in a view narrowing alone (registry meta invisible)', () => {
// `created_at` in a narrowing would be refused by the runtime, but its
// registry-side metadata is not visible to the linter — a judgment here
// risks the false positive ADR-0072 D1 forbids, so it is a documented
// missed finding instead.
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: { name: { type: 'text' } },
listViews: { all: { type: 'grid', searchableFields: ['name', 'created_at'] } },
},
],
});

expect(findings).toEqual([]);
});

it('does not type-check the object\'s own canonical set (runtime honors it)', () => {
const findings = validateSearchableFields({
objects: [
{
name: 'crm_account',
fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } },
searchableFields: ['name', 'owner_ref'],
},
],
});

expect(findings).toEqual([]);
});

it('skips a view bound to an object this stack does not define', () => {
// The object may come from another package; a field map we cannot see
// cannot be judged — the same skip the page/flow/widget rules take.
Expand Down
Loading
Loading