Skip to Content
核心概念查詢模型

查詢資料列:篩選、排序與檢視

自訂資料表提供的不只「列出全部」。同一套型別化 predicate 會出現在搜尋、彙總、規則條件與列級 ACL;先理解共同的葉節點,再分清各 endpoint 外層 payload,才能避免看似成功卻查不到資料的請求。

目前資料列的可編輯性與待審標記(v5.10.0)

Private 資料列回應必定包含 can_edit 布林值,可用來決定是否顯示編輯、刪除、還原、排序或版本還原操作。它依呼叫者的 manager/all/own/filtered/none edit ACL 判斷目前這一列。只有 insert、沒有 edit 權限的使用者,即使新增成功,也可能收到 can_edit: false

can_editpending_approval 獨立。資料列可以通過 edit ACL,卻仍因審核暫存或其他寫入規則而無法異動。每次寫入都會重新檢查當前授權、欄位權限、新值與規則;true 不保證之後的寫入一定成功。更新回應與 command post-image 依持久化後的新資料列重新判斷。

POST .../records/searchCustomTableQueryRequest 接受可省略的 editablepending_approval 布林值。GET .../records 雖會回傳 can_edit,但不接受這兩個 query parameters;要篩選請用 POST search:

{ "editable": true, "pending_approval": false, "limit": 20, "offset": 0 }

兩個條件都會在可讀列、SCP 與內容篩選範圍內以 AND 合併,先篩選,再計算 total、排序與分頁。省略或 JSON null 表示不限制。editable=false 是可讀列中的補集,也包含 own-only edit grant 下沒有建立者的列;manager/all edit grant 使用 false 時會得到空集合。pending_approval=true 選的是既存列上非 null 的 staged-change 標記,尚未產生實體資料列的待審新增不在其中。

這是 private POST search 的本次請求篩選,不能放進已儲存 view config、匯出 payload、public-read 或 command query 參數。View、匯出與公開讀取仍使用各自的 schema。

共同 predicate 形狀

基礎篩選條件是一個三欄物件:

{ "column": "總金額", "op": "gte", "value": 1000 }

基礎 FilterPredicate 的運算子為:

op意義value 形狀
eq / neq等於/不等於與欄位型別相符的單一值
gt / gte / lt / lte大於/大於等於/小於/小於等於數字、日期等可排序的單一值
in落在集合內非空陣列,最多 100 個純量
contains文字包含單一字串;是否可用仍看欄位型別
is_null / is_not_null是/不是 null省略 value

所有 predicate 在同一個 list 內通常以 AND 合併。欄位型別仍是最後限制:例如計算欄不一定支援 contains,link 也不是一般純量。

Null 語意

空 cell——key 不存在、明確的 JSON null、或空字串 ""——永遠不會被任何值運算子選中:eqneqincontains 都不會。只有 is_null 會選到它,而 is_not_null 正好是它的補集。這條規則在每一條會編譯 filter 的通道上都相同——REST stored_filters、agent 工具組的查詢工具、command 的 query 通道、row policy——而且是在 SQL 層強制執行,所以即使 MySQL 把省略的欄位存成明確的 JSON null(unquote 之後是字串 null),空 cell 也不會從 neq 漏出來。這一點對計算欄與 neq 最重要:status neq "closed" 回的是 status 是別的值的列,絕不會回沒有 status 的列。

運算子詞彙依 surface 分成兩套

上表這十個運算子屬於 config surface 詞彙 — 也就是被「保存為設定、之後才評估」的那一類。Query surface 詞彙是它的嚴格超集,多出六個運算子,而且只有查詢通道會接受:

op意義value 形狀
between含上下界的區間剛好兩個純量,[low, high]
within_last以現在為結束的滾動視窗RelativeDateValue 物件
within_next以現在為起點的滾動視窗RelativeDateValue 物件
older_than早於某個滾動截止點RelativeDateValue 物件
is_empty / is_not_emptylink 是否有連結,或 attachment 是否空省略 value

哪個 surface 接受哪一套不是風格偏好,而是雙重把關的規則:

  • Query surfaces(六個新運算子全部可用)POST .../records/searchPOST .../records/export、saved view 的 config,以及 public-read 的 POST /read/{token_id}/query
  • Config surfaces(只有基礎十個):規則/trigger 的 when、ACL row policy(read_filteredit_filter)、SCP policy、rollup filter,以及 POST .../records/aggregatefilters

Config surface 收到 query-only 運算子時會被拒絕:filter op '<op>' is not permitted on this surface。這些 surface 的 schema model 根本沒有宣告那六個運算子,所以多數 authoring 錯誤會更早失敗;但評估時的那道閘門才是權威,並且 fail-closed。

實務結論:「最近 30 天的訂單」不能存進規則條件、row policy、SCP policy 或 rollup filter;但可以存進 saved view。

REST search 有四條篩選通道

POST .../tables/{table_id}/records/search 的 body 是 CustomTableQueryRequest,而且自 2026-07-28 版起,body 裡的未知 key 會回 422、不再被無聲忽略——最經典的受害者是 expand_links,它屬於 query string,不是 body。model 保留舊的簡易 map、三條 predicate 通道,再加上多欄排序:

{ "filters": { "col_a1111111_1111_4111_8111_111111111111": "急件" }, "stored_filters": [ { "column": "col_b2222222_2222_4222_8222_222222222222", "op": "between", "value": [1000, 5000] } ], "computed_filters": [ { "column": "含稅總額", "op": "gte", "value": 1200 } ], "any_of": [ { "stored_filters": [ { "column": "col_c3333333_3333_4333_8333_333333333333", "op": "eq", "value": "open" }, { "column": "col_d4444444_4444_4444_8444_444444444444", "op": "eq", "value": "high" } ] }, { "stored_filters": [ { "column": "col_c3333333_3333_4333_8333_333333333333", "op": "eq", "value": "review" } ] } ], "q": "台中", "sort": [ { "column": "col_b2222222_2222_4222_8222_222222222222", "order": "desc" }, { "column": "created_at", "order": "desc" } ], "limit": 50, "offset": 0 }

所有篩選來源彼此以 AND 合併:

  • filters{internal_column_key: value} 的 legacy map,不是 predicate list。string/text 值做不分大小寫的部分比對,其他純量做精確比對;multi_select 傳一個 option 字串時做陣列 membership。
  • stored_filters 是 stored scalar、link 與 attachment 欄的型別化 predicate,最多 20 條。除了基礎運算子,它還接受 between、三個 relative-date 運算子與兩個 presence 運算子;column 必須是本表內部鍵。依欄位類別:
    • Link 欄接受 eqinis_emptyis_not_empty;其他運算子是 400 link column filter supports only op 'in'/'eq'/'is_empty'/'is_not_empty' (got '<op>')
    • Attachment接受 is_emptyis_not_empty;其他一律 400 attachment columns cannot be filtered
    • json 與三種 principal 型別 principalusersocial_client 只支援等值類:eqneqinis_nullis_not_nullusersocial_client 的運算元必須是非空的原始 id 字串;principal 的運算元必須是帶標籤的 celluser:<id>smc:<id>room:<id>)——裸 id 是 400,不是零列結果。任何情況都不能傳數字或顯示名稱。詳見 JSON 欄位Principal 欄位
    • 接受的系統欄是 created_atupdated_atidid 對準資料列真正的主鍵——也就是 create、list、search 回給你的那個 id——而且只支援不透明識別碼的運算子集合:eqneqinis_nullis_not_null;排序與子字串運算子用在 id 上是 400。(2026-07-28 版之前,id 條件會被編譯到 data blob 裡的幽靈 uuid 上,回 200 卻比對到 0 筆——見系統 id 欄位。)
  • computed_filters 用於 rollup、cardinality-one lookup、pick cardinality-many lookup 與 formula,最多 3 條;column 可用顯示名稱或內部鍵。link、未帶 pick cardinality-many lookup、計算欄 contains 不在此通道;表超過 50,000 筆 live rows 時會直接拒絕:這道守衛在任何 predicate 編譯之前就讀取整張表的 live 筆數,所以再加一般 filter 也解不開,只有把表變小才行。
  • any_of 是 OR 群組清單,見下文〈OR 群組:any_of〉。
  • q 在呼叫者可見的所有 string/text 欄位做不分大小寫的全域 substring OR 搜尋,再與其他通道 AND 合併。隱藏欄位不參與——schema 自動加入的 id 項目也不參與:它的型別是 string 欄位,但從來不裝資料列的 id(見系統 id 欄位)。

filtersstored_filtersany_of[].stored_filters[].columnsort_bysort[].column 都使用 settings.column_mapping 裡的 col_<hex>;系統欄是上述例外。詳細身分規則見欄位身分

隱藏欄(managers-only)閘門會走訪 any_of 群組裡的每個 predicate 與每個 sort key,不只頂層通道。非管理者引用看不到的欄位時會得到 Column '<name>' does not exist in table schema — 這與「欄位真的不存在」的訊息刻意完全相同,避免請求被拿來當成 membership 或排序 oracle。前端錯誤文案因此不能斷言「沒有這個欄位」。

系統 id 欄位現在對準真正的主鍵

每份 schema 都有一個 id 項目,而客戶端拿在手上的 id 就是資料列的主鍵。查詢通道以前把 id 條件編譯到 data blob 裡另外鑄造的幽靈 uuid 上,整條線都在無聲給錯答案:id 篩選回 200 卻 0 筆、sort_by: "id" 排出毫無意義的順序、對 idgroup_by 的分組 key 沒有人解析得到、count::id 只是碰巧等於列數。現在所有讀取通道都對準真正的主鍵:

通道id 的語意
stored_filters / any_ofeqneqinis_nullis_not_null;排序/子字串運算子 → 400
舊式 filters map精確主鍵比對——字串或字串清單;其他形狀 → 400
單欄 sort_by真正的主鍵(多欄 sort 原本就是)
aggregate group_by逐筆分組,key 是解析得到的那個 id
aggregate 指標只有 countcount_distinct;對 idsum/avg/min/max → 400
q OR 搜尋不再探測 id

兩個值得動手的後果:

  • 如果你的 client 曾經「用 id 篩選、拿到 0 筆、於是繞路」——把繞路拆掉;現在篩選比對得到了。
  • 同一個修正也進了存量設定:rules 的 existsnot_exists where 條件、rollup 與計算欄位的 filter、row policy、upsert 的 match-by-id,以前全部無聲比對不到,現在都是活的。在假設行為不變之前,先盤點所有提到 id 的存量設定;撰寫端則在文法撐不起語意的地方直接拒絕 id(SCP 的 scalar leaf、no_overlap 的欄位槽)。

日期時間邊界現在大聲失敗

datedatetime 儲存格是正規字串(YYYY-MM-DDYYYY-MM-DD HH:MM)、以字典序比較,所以其他形狀的邊界以前會悄悄移動比較結果:帶秒數的 "2026-01-01 02:35:00" 對上儲存格 "2026-01-01 02:35",每個 eq/lte/gte 邊界都偏了;"2026/01/01" 什麼都比不到——而全部都回 200。現在 stored_filtersany_of 的邊界跟其他通道一樣先驗證:in 逐項驗(而且必須是清單——裸字串以前會被逐字元疊代),between 驗兩端與個數。非正規形狀在請求當下就是 400

一個遷移注意:存了非正規邊界的 saved view 或 IaC spec,讀取時現在回 400,不再無聲回傳偏移的結果。大聲失敗正是這次修正的重點——修掉存下來的邊界,不要想辦法固定舊回應。真正的 created_atupdated_at 欄位是真 DATETIME 比較,秒數在那裡仍然合法。

OR 群組:any_of

any_of 是在伺服器端表達 OR 的唯一方式。同一群組內的 predicate 以 AND 合併,群組之間以 OR 合併,整個 any_of 區塊再與 filtersstored_filterscomputed_filtersq 以 AND 合併:

{ "any_of": [ { "stored_filters": [ { "column": "col_c33…", "op": "eq", "value": "open" }, { "column": "col_d44…", "op": "eq", "value": "high" } ] }, { "stored_filters": [ { "column": "col_c33…", "op": "eq", "value": "review" } ] } ] }

意義是 (status = open AND priority = high) OR (status = review)

上限與拒絕規則:

  • 最多 10 個群組,每個群組最多 10 條 predicate
  • 每個群組至少要有一條 predicate,而且 any_of: []422 — 空的 disjunction 絕不會被解讀成 match-all。UI 若是逐步累積群組,在使用者尚未加入任何群組時必須省略這個 key,不要送 []
  • 群組內的 predicate 與 stored_filters 共用同一套形狀、欄位規則與運算子規則。

唯一的例外在 view apply:若 saved view 的 config 已經存了 any_of: [],套用時會被視為「沒有條件」而不是讓一個已保存的 view 直接失敗。不要把 [] 當成可 authoring 的值。

不要把整份資料抓回前端再自己做 OR。那會讓分頁總數錯誤,也會讓 aggregate/export 變成另一套語意。

相對日期視窗

三個運算子用「相對於現在」表達視窗,而不是寫死一個時間戳。它們的 valueRelativeDateValue 物件:

{ "column": "col_a1111111_1111_4111_8111_111111111111", "op": "within_last", "value": { "amount": 30, "unit": "days", "tz_offset_minutes": 480 } }
欄位型別限制
amountinteger1 到 730
unitstringdayshours
tz_offset_minutesinteger−840 到 840,預設 0

視窗會在每一次查詢執行時,以伺服器當下的 UTC 時間解析成絕對的半開區間邊界:

op視窗編譯結果
within_last(now − amount, now]col > lo AND col <= hi
within_next[now, now + amount)col >= lo AND col < hi
older_than早於 now − amount 的一切col < cutoff

互補的邊界只會讓 datetimecreated_atupdated_at 乾淨切開。date 欄不同:兩個邊界都會截斷成 YYYY-MM-DD,因此落在目前有效日曆日期的資料列,可能同時命中相鄰、以天為單位的 within_lastwithin_next 視窗。

必須記住的規則:

  • 絕不會存下絕對時間。 Saved view 或 IaC 文件保存的是相對形式,每次 apply 重新解析;因此「最近 30 天」的 view 永遠是滾動視窗,plan hash 也不會每天飄移。
  • 只能用在 date / datetime — 以及真正的 created_at / updated_at。其他型別是 400 filter op '<op>' is only valid on date/datetime columns。存著日期字樣的 string 欄不會生效,要先改型別。
  • tz_offset_minutes 平移 date 欄的日界。對 datetimecreated_atupdated_at 而言視窗是純粹的滾動 UTC 區間,這個 offset 沒有作用 — 台北的 client 在 datetime 欄傳 480 不會有任何位移。

date 欄使用 unit: "hours" 會得到空結果。date-only 欄會把兩個邊界都截斷成 YYYY-MM-DD,所以不到一天的視窗會編譯成 col > '2026-07-25' AND col <= '2026-07-25',一列都不會命中(within_next 同理)。date 欄請用 unit: "days"unit: "hours" 留給 datetimecreated_atupdated_at。(older_than 搭配 hours 是安全的,它只需要單一截止點。)

邊界值以 cell 的儲存語法輸出 — date 是 YYYY-MM-DD,datetime 是 YYYY-MM-DD HH:MM,以空白分隔、精度到分鐘,絕不是 ISO 的 T。因為 cell 只到分鐘而 now 帶秒,每個邊界會朝著自己的運算子取整:within_last 兩端下取整、within_next 兩端上取整、older_than 對截止點上取整。

cell 語法本身見日期與日期時間欄位

沒有「本月」這個運算子

calendar_period 這類運算子(this_monthlast_quarter…)不存在。它在 2026-07-25 被審視後明確否決:那是前端與 IaC 都會永久繼承的查詢詞彙,而且沒有實際需求。這個決定用 strict-xfail 測試釘住,任何人補上該運算子都會得到紅燈,而不是讓 surface 悄悄變大。

滾動運算子不是它的替代品,因為兩者回答不同問題:在 25 號那天,within_last 30 days 涵蓋上個月 25 號到今天,會包含上個月的資料列。日曆視窗請用 between 搭配呼叫端自行算出的明確邊界:

{ "stored_filters": [ { "column": "col_a1111111_1111_4111_8111_111111111111", "op": "between", "value": ["2026-07-01", "2026-07-31"] } ] }

Presence:is_emptyis_not_empty

這兩個運算子問的是「有沒有」,不是「是不是 null」,而且只在兩類欄位上合法:

  • link 欄上,它們編譯成對 link 表的 EXISTS / NOT EXISTS — 「這一列到底有沒有連到任何東西」。這是純索引的存在性判斷,因此不像 computed_filters 有列數上限;任何大小的表都能做 link presence 篩選。
  • attachment 欄上,它們的語意是 null-or-empty-array:缺 key、明確的 JSON null、長度為 0 的陣列,三者都算空。

兩者都必須省略 value;帶了值會得到 422 filter op '<op>' must not set a value

is_nullis_empty 不是同義詞,也不能互換。is_null / is_not_null 看的是純量 cell 是否為——key 不存在、明確的 JSON null、空字串 "" 在每一條通道上都算空;is_empty / is_not_empty 看的是 link 是否有連結、attachment 陣列是否為空。用錯欄位類別就是 400:對純量、jsonmulti_select 或真正的 created_atupdated_at 使用 presence 運算子會回 filter op '<op>' is only valid on link or attachment columns

multi-select 在兩條 REST 篩選通道的語意不同

REST stored_filters 在機制上會接受 multi_select 欄位,但不會做 element-aware membership;predicate 比較的是整個 JSON_UNQUOTE 陣列編碼。Presence 運算子也沒有改變這項行為:對 multi_select 欄使用 is_emptyis_not_empty 仍是硬性 400,因為兩者只適用於 link 與 attachment 欄位。REST search 若要判斷「包含某個 option」,請使用 legacy map:

{ "filters": { "col_c3333333_3333_4333_8333_333333333333": "急件" } }

自訂資料表的分析工具則使用另一個 FilterInput 外層:{"column", "operator", "value"},欄位採顯示名稱,並完整支援 list-aware multi-select 語意:

  • eqcontains:陣列包含指定 option;in:包含清單中的任一 option。
  • neqnot_contains:不含指定 option;not_in:不含清單中的所有 options。
  • is_empty / is_not_empty:檢查陣列本身是否空;is_null / is_not_null:檢查 cell 是否為空(key 不存在、JSON null""),與 REST 同一條規則。

分析工具還有 starts_withends_withlikenot_inbetween 等延伸運算子,而且它的 any_of 是「list of list」,不是 REST 的 {stored_filters: [...]} 外層。它也支援 within_lastwithin_nextolder_thanquery_recordsaggregate_records 可用於 datedatetime 欄位及 created_atupdated_atjoin_aggregate 則只接受 datedatetime 資料欄位。兩邊的 payload 仍不能互相複製;請依你正在呼叫的 reference schema 組 payload。值的形狀與精確型別閘門見 Agent 工具箱的滾動時間窗

List 與 search 怎麼選

GET .../records 適合初次載入 grid:提供 skiplimitsort_bysort_order、建立日期 fromto 與可選的 expand_links,但沒有複合 predicate body。它沒有取得 any_of、多欄 sort、relative-date 或 presence 運算子 — 這四項只存在於 request body 的通道。

POST .../records/search 適合搜尋面板、進階篩選與計算欄條件。它使用 limitoffset,可合併 q、四條篩選通道與任一種排序形式。兩者都回 recordstotal 與含 schema/mapping 的 table,也都先套用同一份有效 ACL。

不要在前端先抓完整資料再過濾。這會讓分頁總數錯誤、漏套 filtered ACL,也會把 aggregate/export 變成另一套語意。

排序與分頁

排序有兩種形式,而且互斥:

  • 單欄sort_by(內部鍵)加 sort_orderascdesc)。它接受 stored scalar、idcreated_atupdated_at,以及 rollup/cardinality-one lookup/pick cardinality-many lookup/formula(受 50,000 列計算欄排序保護限制)。
  • 多欄sort,最多 3{column, order} key,依清單順序套用,最後再補上 CustomTableRecord.id ASC 作為穩定 tiebreaker。integer/float key 會轉成數值型別,因此是數值排序而不是字典序。

兩者同時送出是 422:Provide either 'sort' (multi-column) or 'sort_by'/'sort_order', not both. UI 若在 state 保留了預設 sort_by,加入多欄排序前必須清掉它。

多欄排序可用的 key 集合刻意比 sort_by 更窄:

Key可用於 sort
stored scalar 欄可以
idcreated_atupdated_at可以
sort_order(手動拖曳的列順序)只在 records/search 可用 — saved view 與 public-read query 會拒絕
rollup/lookup/formula/link不行 — 計算欄只能用 sort_by 單欄排序
attachment、jsonprincipalusersocial_client不行

未知的 key 現在是 400 Sort column '<col>' does not exist in table schema。這是行為變更:以前傳顯示名稱或打錯字時會退回 string 型別、對不存在的 JSON key 編譯,然後回 HTTP 200 但排序全為 NULL。sort[].column 必須是內部 col_<hex> key。被封鎖的欄位型別同樣是 400,但訊息措辭依 scope 而異 — chatroom search 通道回不含欄名的版本(Sorting on attachment columns is not supported),department/company 則回含欄名的版本 — 所以不要從 detail 裡剖析欄位名稱。

空的 sort: [] 到處都被接受,只是會退回 sort_by;只有 any_of 拒絕空清單。

常見分頁欄位有四組命名:

Surface分頁欄位
records GET、saved-view applyskip + limit
records/search POSToffset + limit
分析工具1-based page + page_size
Query-mode command(POST .../commands/{id}/query不透明的 page.cursor + page.limit,回應含 next_cursor + total_available

各 surface 的列數上限並不相同,沒有單一數字:

  • records/searchrecords/exportlimit 仍是 1..1000。
  • Query-mode command 的 page.limit 是 1..100,預設 100。
  • Public-read query 的 limit 是 1..100、預設 50;public GET /read/{token_id}/recordslimit query parameter 上限同樣是 100
  • 分析工具的 page_size 上限是 100

永遠以回應的 totaltotal_pages 畫分頁,不要用「這頁是否剛好滿」猜還有沒有下一頁。換頁時也要保持排序條件不變,否則列可能重複或跳頁 — 使用多欄 sort 時,補在最後的 record-id tiebreaker 已保證兩次相同請求不會把等值列重新洗牌。

sort_order 是一個「槽位」,而槽位涵蓋已刪除的資料列

sort_order 不只是排序 key,它是每張表的整數槽位,而 uq_table_sort_order(table_id, sort_order) 上的 unique constraint,範圍是整張 records 表——包含已被 soft delete 的資料列。但所有「下一個槽位」的計算以前都只看 live 列,這個「域不一致」有一個確定性的失敗模式。

現行的刪除通道會把 sort_order 設成 NULL、把舊值收進 deleted_sort_order,所以健康的表不會有已刪除列還佔著槽位。但在這個行為存在之前被 soft delete 的列,以及被 migration 原樣複製過來的列,仍然佔著。這種表裡,只看 live 列的 MAX(sort_order) 會少讀,insert listener 就發出一個已經被佔用的槽位,寫入死在 constraint 上:409 Duplicate value '<table_id>-<slot>' violates unique constraint 'uq_table_sort_order'.——而且是那張表的每一次 insert 都失敗,不是偶發。回報時的樣子是一個全新的部門一筆資料都建不起來。

槽位運算現在涵蓋和 constraint 相同的資料列範圍,五個計算點全部改:單筆建立背後的 before_insert 槽位指派、同步 POST .../records/bulk 通道預先算的 max、非同步 POST .../records/bulk-insert 通道的同一段、測試資料插入,以及 POST .../records/{record_id}/restoremax + 1 fallback。呼叫端看到的是 insert 落在 (全表 MAX) + 1,而且之後每次都繼續落得下去。沒有 dead-held 槽位的表則完全沒有變化——MAX() 會忽略 NULL,所以全表 max 等於 live max。

真正被保證的事情比「槽位永不重用」窄,這裡講明以免你讀過頭:被佔用的槽位絕不會被重新發出。 刪除仍然會把那個整數釋放出來——刪掉頂端槽位的那一列,MAX 就少一,下一次 insert 會把同一個數字用在另一筆資料上。deleted_sort_order 只記錄這一列原本坐在哪裡;restore 拿它做什麼,見軟刪除、trash 與版本歷史

重排到被佔用的槽位現在是乾淨的 400

PUT .../records/order 在寫入之前,會先把要求的槽位拿去和 reorder 集合以外的資料列比對。這個 probe 以前只看 live 列,所以被 soft-deleted 列佔著的槽位可以通過,接著寫入在 flush 時炸掉,回同一個原始的 409 Duplicate value '<table_id>-<slot>' violates unique constraint 'uq_table_sort_order'.——這是資料庫形狀的答案,既沒有指出你該改哪個要求的槽位,也沒有給你一個可用的空槽位,因為已刪除列在任何讀取介面上都看不到。

Probe 現在涵蓋全表,所以這一類輸入在任何寫入之前就被擋下,回 400

{ "detail": "sort_order values [3, 7] already used by other records in this table" }

槽位以遞增順序列出。這個字串本身不是新的——它本來就會在和 live 列衝突時出現;改變的是 dead-held 槽位現在會走到它,而不是死在 flush。

同一個函式裡有一個刻意保留、未變更的不對稱:probe 上方的資料列存在性檢查仍然只看 live、在 scope 內的資料列。Reorder 用兩趟 bulk UPDATE 寫入,完全不會觸發資料列寫入 floor,所以那個受限的 SELECT 是唯一擋住某個房間去重排 scope 外資料列的東西——scope 外的 id 會落進 400 Records not found in table: <ids>。單次 1000 筆上限、以及 record_id 不得重複/sort_order 不得重複的規則都沒有動。

Lookup、aggregate 與 export

這三個端點服務不同問題:

  • POST .../records/lookup 接一組已知 record IDs,用於把 link cell 的 ID 批次解析;它不是文字搜尋,會依請求順序解析目標 id;不存在或已刪除的 id 不會出現在結果中,但呼叫者無權讀取的 id 仍會以只含 id 的 stub(readable: false)回傳 — 渲染前務必先判斷 readable。
  • POST .../records/aggregate 在資料庫中算 group 與 metric。它的 filters 是基礎 {column, op, value} list,stored scalar only,最多 10 條;group_by 與 metric 欄由 schema 驗證。結果只涵蓋呼叫者可讀的 live rows。Aggregate 沒有取得新的查詢詞彙:沒有 any_of、沒有 sort、沒有 relative-date、也沒有 presence 運算子——而且現在送這些(或任何未知 key)是 422;以前會回 200、多的 key 被無聲丟掉,等於整個 aggregate 沒過濾就跑完。對 id 欄位,合法的指標只有 countcount_distinct;對 idsum/avg/min/max400
  • POST .../records/export 接受與 search 相同的 legacy/stored/computed/q/any_of/單欄/多欄 sort 設定(包含同樣的 any_of: [] 與排序互斥 422),串流輸出 CSV 或 XLSX。檔案只有可見列與可見欄。列數上限是在套用 any_ofsort 之後、開始輸出位元組之前評估的,因此匹配超過 100,000 列時要先縮小篩選。

若需求是「先找到訂單,再顯示 link 指向的客戶」,先 search 訂單,再收集 target IDs 做一次 lookup;不要逐列發 request。

唯讀的 command 查詢

Command 現在有兩條通道。宣告 mode: "query" 的 command 不含任何寫入 step,改由 POST .../commands/{command_id}/query 呼叫:它不上鎖、不保留 idempotency key、不寫執行稽核列,結束時直接 rollback session;read ACL 仍然強制執行。它以不透明的 next_cursor 分頁,page.limit 上限 100;cursor 綁定該次 command 的已驗證 inputs 與 schema dependency contract,任一改變就會得到 400 cursor_binding_mismatch — 此時要從第 1 頁重新開始,不要重試舊 cursor。

兩條通道走錯都不會做任何事:對 write command 呼叫 /query 是 409 not_a_query_command;對 query command 呼叫 /execute 是 409 query_command_requires_query_route,而且在任何 idempotency 保留之前就先擋下。完整契約見 query-mode command

Saved view 是保存的查詢,不是快照

Saved view 的 config 保存 filtersstored_filterscomputed_filtersany_ofqsort_bysort_ordersort,以及要顯示的 columns。查詢鍵仍遵守 search 規則;只有 columns 保存顯示名稱。分頁不會存進 view,而是在每次 apply 時以 skiplimit 傳入。

新欄位有兩條專屬規則:

  • relative-date predicate 保持相對形式,每次 apply 才解析成絕對邊界。這就是滾動「最近 7 天」view 不需要有人定期改寫的原因。
  • View 的 sort 不能sort_order 當 key,即使 records/search 接受它:view 的系統欄集合只有 idcreated_atupdated_at,因此會回 Column 'sort_order' does not exist in table schema。Public-read query 走的是同一個 validator。

套用 view 時,伺服器會用當下資料與當下呼叫者 ACL 重新驗證,並且一併走訪 any_of 群組與 sort key 裡的欄位引用。共享 view 不會凍結結果,也不會讓建立者可見的隱藏欄漏給其他讀者。GET 與 POST apply 只是兩個相容入口,都套用已保存的 config,不接受臨時覆寫 body。

ACL filtered 與規則 when 的共用語意

列級 ACL 的 can_read: "filtered"can_edit: "filtered" 也使用相同 predicate 葉節點,但外層固定是非空 AND:

{ "can_read": "filtered", "read_filter": { "and": [ { "column": "col_d4444444_4444_4444_8444_444444444444", "op": "eq", "value": "公開" } ] } }

ACL 欄位必須是 stored scalar 的內部鍵。filtered 沒有 filter,或非 filtered 卻帶 filter,都會被拒絕;讀與寫會在 SQL 層套用這些條件。

規則與 trigger 的 when 則直接保存最多 5 條 predicate,欄位 authoring 可用顯示名稱或內部鍵,持久化後為內部鍵。它控制「規則何時生效」,不是挑出 API 回應列。

這兩者都是 config surface,因此只能使用基礎十個運算子:betweenwithin_lastwithin_nextolder_thanis_emptyis_not_empty 在這裡會被 filter op '<op>' is not permitted on this surface 拒絕,SCP policy 與 rollup filter 亦同。所有 surface 共用 op/null/type 語意,但外層 payload、命名契約與運算子詞彙都不同。

完整 endpoint payload 見資料列參考檢視參考;要實際比較 list/search,可在 API Playground 使用同一張表發送兩種請求。

Last updated on