Skip to content

fix(core): 已保存视图的 ViewFilterRule[] 在进 $filter 前折成 AST (#3431) - #3471

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3431-savedview-filter-fold-read
Aug 6, 2026
Merged

fix(core): 已保存视图的 ViewFilterRule[] 在进 $filter 前折成 AST (#3431)#3471
yinlianghui merged 1 commit into
mainfrom
claude/issue-3431-savedview-filter-fold-read

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3431

实证结论:服务端不接受 rule 数组(issue 的可能性 2 成立)

先给结论和证据,再谈修法。用 e2e/live/ci/start-backend.sh 起了一套独立测试栈(@objectstack/*@17.0.0-rc.2 —— 与 pnpm-lock.yaml@objectstack/spec 同一枚 pin,即 live-e2e lane 用的那对),跑 app-showcase,端口 4031,--fresh 临时库。

showcase 自带一条正好命中本 issue 的已保存视图(src/ui/views/task.view.ts):

listViews.in_progress = { …, filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] }

/api/v1/meta/view?object=showcase_task 读回来的就是这个 rule 数组。直接打数据接口:

GET /api/v1/data/showcase_task?$filter=[{"field":"status","operator":"equals","value":"in_progress"}]
  -> HTTP 400 {"error":"Request failed","code":"INVALID_FILTER","object":"showcase_task"}

GET /api/v1/data/showcase_task?$filter=[["status","equals","in_progress"]]
  -> HTTP 200 {"total":2}   # 10 条任务里正确筛出 2 条

不是猜的、也不是只看静态代码:又用仓库自己的源码把整条读路径复现了一遍(取 /meta/view 的真实 metadata → ObjectView.tsxbase/baseArr/combined 表达式 → resolveFilterPlaceholdersmergeFilterNodes → 打真实后端):

修复前 修复后
1) 落盘 viewDef.filter [{"field":"status","operator":"equals","value":"in_progress"}] 同左(未被污染)
2) ListViewSchema.filter 同上 同上(仍是 spec 形状)
3) 上线的 $filter [{"field":"status",…}] [["status","equals","in_progress"]]
4) 服务端 HTTP 400 INVALID_FILTER HTTP 200,total=2

也就是说:今天每一条带 filter 的已保存视图都是坏的 —— 不是"筛错",是 400、一行都不出(record-count-bardata.length > 0 时才挂载,所以连计数条都不会出现)。

静态侧与之一致(用仓库 pin 的 @objectstack/spec@17.0.0-rc.2 实跑):isFilterAST([{field,…}])false,parseFilterAST 返回 undefined;协议层 metadata-protocol/protocol.tsisFilterAST 判否且非空的数组直接 malformedFilterArrayError → 400(objectstack#4121)。

缝口裁决:折叠放在 core/filter-converter.tstoFilterNode,不是 ObjectView

派单倾向读侧 ObjectView,允许"若正确缝口在 core 则给出论证"。论证如下,四条,最后一条是决定性的:

  1. toFilterNode 本来就声明了这个形状。 它自己的注释写着"三种都合法"的第一种就是 [{ field, operator, value }, ...] 一个 spec ViewFilterRule[] —— 文档写了、实现没做。紧邻的 mergeFilterNodes 注释更是精准预言了本 bug("bare rule OBJECTS where the AST expects nodes … isFilterAST says no"),而它依赖的 sink 从没实现它描述的下降。
  2. 两个生产者共用这一个 sink。 plugin-listbuildEffectiveFilter(喂 grid 和导出)和 plugin-view/src/ObjectView.tsx:432(currentNamedViewConfig?.filter || activeView?.filter,喂 calendar / kanban / gallery / timeline)。只在 app-shell 的 ObjectView 折,后者仍然坏 —— 而它不在本次 fence 内。
  3. 判别是结构性的,不是猜。 AST 节点一律是数组,rule 一律是对象,两者永不混淆。同函数内已有先例:MongoDB 形状就是在这里经 convertFiltersToAST 下降的。
  4. 决定性的一条(#0.1 的反面):ListViewSchema.filter / ViewTab.filter 在 spec 里声明为 z.array(ViewFilterRuleSchema),而 ObjectView 交给 ListView 的就是一个 ListViewSchema在 ObjectView 折,等于把 ObjectQL AST 三元组写进一个 spec 声明为 rule 数组的槽位 —— 往 spec 字段里塞 off-spec 值,正是 AGENTS.md #0.1 的镜像违规。下降必须发生在离开 spec 词汇、进入线上 AST 的最后一跳,那一跳就是 toFilterNode

副作用:ObjectView.tsx 一个字节没动,URL 三元组(?filter[FIELD]=VALUE)的处理原样保留,substituteFilterTokens 也仍然作用在它的 resolver 本来就为之编写的 rule 形状上。

算子:没有第二张 canonical 表

算子过 spec 自己导出的 normalizeFilterOperator(@objectstack/spec/ui)—— 写入侧 viewFilterFold.ts 用的同一个出口,两个方向不可能漂成两种方言。

而且这里根本不需要映射表:spec 的 19 个 VIEW_FILTER_OPERATORS 已经全部是线上 VALID_AST_OPERATORS 的成员,所以下降是纯结构性的({field,operator,value}[field,operator,value])。spec 不认识的拼法原样透传,让 isFilterAST 照样判否、服务端照样 400 —— 拼错必须大声失败,绝不能被"纠正"成一个合法算子。

两处刻意的取舍,都实测过:

  • value 的 rule 只发两元节点。 is_empty / is_null 的方向来自算子名字;补第三位会在序列化时凭空造出一个 null(数组空洞 → null),['x','equals',null] 是一条作者从没写过的真实 {x: null} 谓词。与写入侧 if (c.value !== undefined) 同规则。
  • field 为空串的 rule 不折。 实测 [["","equals","x"]] 能过 isFilterAST,服务端回 200 + total: 0 —— 一个静默的空列表;不折则它仍是 AST 位置上的对象,服务端 400 INVALID_FILTER 并点名该元素。响亮胜过静默空集。与写入侧丢弃空白行的判别一致。

测试

反向验证方向(先预测、后执行):预测为常规 RED —— 新用例断言的是"产出了某个值"且 isFilterAST(...) === true,不是"某物没被产出",所以拿掉折叠必然红,不存在"空集导致的假绿"。执行结果与预测一致:把 toFilterNode 的数组分支改回 return source as FilterNode,9 failed / 13 passed;恢复后 22 全绿。

pnpm exec vitest run packages/core packages/plugin-list packages/plugin-view
  -> Test Files 96 passed (96) | Tests 1838 passed (1838)

pnpm exec vitest run packages/app-shell packages/plugin-grid packages/data-objectstack
  -> Test Files 356 passed (356) | Tests 3211 passed | 1 skipped

pnpm --filter '@object-ui/core' type-check                        -> Done
pnpm --filter '@object-ui/plugin-view' --filter '@object-ui/plugin-list' type-check -> Done
eslint(4 个改动文件)                                             -> 0 errors(仅既有 any warning)
node scripts/check-control-bytes.mjs                              -> OK

fixture 三分处置(逐条重判,不是批量改拼写)

  • packages/core/.../filter-source-merge.test.tspasses a non-empty array source through unchanged —— 整条替换。它把缺陷本身钉成了正确行为,并且用一句"the adapter translates it on the way out"给自己作了担保。没有任何 adapter 做这件事,上面的 400 就是证据。现在钉住下降结果,并用 @objectstack/spec/dataisFilterAST / parseFilterAST(后端跑的同一份代码)做判官,而不是复述一个字面量。
  • mergeFilterNodeswraps each source as its own child —— 改期望值。"每个 source 各自成子、绝不 spread"的意图存活,期望值随下降更新。
  • packages/plugin-view/.../ObjectView.filterSources.test.tsxkeeps a ViewFilterRule[] —— 整条替换,且这条是跨包扫出来的:改动在 packages/core,坏掉的 fixture 在 packages/plugin-view(本 PR 认定的第二个生产者)。按"编辑的包"扫会漏,按规则的消费半径扫才扫得到。

live e2e

新增 e2e/live/saved-view-filter.spec.ts:直接进 showcase_task.in_progress 这条已保存视图,断言(a)record-count-bar 显示 2、(b)/api/v1/data/showcase_task 没有任何 4xx 响应(失败信息会打印被拒请求的状态码、错误 code 和完整 URL,而不是只说"没有行")。

暂未加进 test:e2e:live:ci 白名单:live-e2e.yml 自己写明"新 spec 需先在该 lane 证明无 flake 才可加入白名单",这条还没有运行记录;且 package.json 不在本次 fence 内。建议 nightly 跑过之后由后续 PR 提升。

越界说明

packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx 不在派单 fence("两个包的测试" = app-shell + core)内。它是本次改动的必要 fixture 重判(否则 CI 直接红),属于规则消费半径内的 fixture 扫描结果,非功能改动。没有ListView.tsxpackages/spec、objectstack 仓、content/docs/releases/

顺手记录的越界发现


Generated by Claude Code

…aches $filter (#3431)

`toFilterNode` returned every array source verbatim, so a saved view's stored
`ViewFilterRule[]` travelled the whole read path — ObjectView ->
ListViewSchema.filter -> buildEffectiveFilter -> mergeFilterNodes -> $filter —
as bare rule objects. The server refuses that: `isFilterAST` is false for an
array of objects and the wire face answers 400 INVALID_FILTER, so every saved
view carrying a filter rendered no rows at all.

Verified against a real backend (@objectstack/*@17.0.0-rc.2, app-showcase) on
the SHIPPED `showcase_task.in_progress` view:

  $filter=[{"field":"status","operator":"equals","value":"in_progress"}]
    -> HTTP 400 {"code":"INVALID_FILTER"}
  $filter=[["status","equals","in_progress"]]
    -> HTTP 200, total=2

The lowering lives at `toFilterNode` rather than at a producer because
`ListViewSchema.filter` / `ViewTab.filter` are spec-declared
`z.array(ViewFilterRuleSchema)`: folding one hop earlier would write AST
triples into a spec-declared rule-array slot. This is the last hop before the
wire and the single sink both producers share (plugin-list's
buildEffectiveFilter, which feeds the grid and its export; plugin-view's
ObjectView, which feeds calendar/kanban/gallery/timeline).

Operators canonicalise through the spec's own `normalizeFilterOperator` — the
exact exit the write side (viewFilterFold) uses — so no second operator table
is introduced; all 19 VIEW_FILTER_OPERATORS are already members of
VALID_AST_OPERATORS, making the fold purely structural. Unknown spellings pass
through verbatim so the server still refuses them loudly. Mixed arrays (a
view's rules concatenated with ?filter[...] URL triples) fold element-wise with
the triples untouched; a blank-field rule is deliberately NOT lowered, because
["", op, value] passes isFilterAST and returns an empty list while the
unlowered rule keeps the loud 400.

Two fixtures pinned the old passthrough and are replaced: core's
filter-source-merge ("passes a non-empty array source through unchanged", whose
note explained it away with "the adapter translates it on the way out" — none
does) and plugin-view's ObjectView.filterSources ("keeps a ViewFilterRule[]"),
which sits in the rule's consumption radius outside the edited package.

Fixes #3431

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 6, 2026 8:41am

Request Review

@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 08:43
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 2a9513d Aug 6, 2026
19 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3431-savedview-filter-fold-read branch August 6, 2026 08:43
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-D_L7OkcS.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 21.35KB 5.70KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 5.30KB 2.24KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 479.63KB 105.43KB
core (index.js) 2.47KB 0.91KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.30KB 34.76KB
fields (index.js) 229.92KB 56.48KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.03KB 28.88KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.53KB 57.37KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 185.25KB 49.08KB
plugin-kanban (index.js) 48.03KB 13.22KB
plugin-list (index.js) 105.19KB 25.39KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] 已保存视图的 ViewFilterRule[] 被原样当 FilterNode 送进 $filter,未在客户端折成 AST

2 participants