查詢資料列:篩選、排序與檢視
自訂資料表提供的不只「列出全部」。同一套型別化 predicate 會出現在搜尋、彙總、規則條件與列級 ACL;先理解共同的葉節點,再分清各 endpoint 外層 payload,才能避免看似成功卻查不到資料的請求。
目前資料列的可編輯性與待審標記(v5.10.0)
Private 資料列回應必定包含 can_edit 布林值,可用來決定是否顯示編輯、刪除、還原、排序或版本還原操作。它依呼叫者的 manager/all/own/filtered/none edit ACL 判斷目前這一列。只有 insert、沒有 edit 權限的使用者,即使新增成功,也可能收到 can_edit: false。
can_edit 與 pending_approval 獨立。資料列可以通過 edit ACL,卻仍因審核暫存或其他寫入規則而無法異動。每次寫入都會重新檢查當前授權、欄位權限、新值與規則;true 不保證之後的寫入一定成功。更新回應與 command post-image 依持久化後的新資料列重新判斷。
POST .../records/search 在 CustomTableQueryRequest 接受可省略的 editable 與 pending_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、或空字串 ""——永遠不會被任何值運算子選中:eq、neq、in、contains 都不會。只有 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_empty | link 是否有連結,或 attachment 是否空 | 省略 value |
哪個 surface 接受哪一套不是風格偏好,而是雙重把關的規則:
- Query surfaces(六個新運算子全部可用):
POST .../records/search、POST .../records/export、saved view 的config,以及 public-read 的POST /read/{token_id}/query。 - Config surfaces(只有基礎十個):規則/trigger 的
when、ACL row policy(read_filter/edit_filter)、SCP policy、rollupfilter,以及POST .../records/aggregate的filters。
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 欄接受
eq、in、is_empty、is_not_empty;其他運算子是 400link column filter supports only op 'in'/'eq'/'is_empty'/'is_not_empty' (got '<op>')。 - Attachment 欄只接受
is_empty與is_not_empty;其他一律 400attachment columns cannot be filtered。 json與三種 principal 型別principal、user、social_client只支援等值類:eq、neq、in、is_null、is_not_null。user/social_client的運算元必須是非空的原始 id 字串;principal的運算元必須是帶標籤的 cell(user:<id>、smc:<id>或room:<id>)——裸 id 是 400,不是零列結果。任何情況都不能傳數字或顯示名稱。詳見 JSON 欄位與 Principal 欄位。- 接受的系統欄是
created_at、updated_at與id。id對準資料列真正的主鍵——也就是 create、list、search 回給你的那個 id——而且只支援不透明識別碼的運算子集合:eq、neq、in、is_null、is_not_null;排序與子字串運算子用在id上是 400。(2026-07-28 版之前,id條件會被編譯到 data blob 裡的幽靈 uuid 上,回 200 卻比對到 0 筆——見系統 id 欄位。)
- Link 欄接受
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 欄位)。
filters、stored_filters、any_of[].stored_filters[].column、sort_by、sort[].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" 排出毫無意義的順序、對 id 做 group_by 的分組 key 沒有人解析得到、count::id 只是碰巧等於列數。現在所有讀取通道都對準真正的主鍵:
| 通道 | id 的語意 |
|---|---|
stored_filters / any_of | eq、neq、in、is_null、is_not_null;排序/子字串運算子 → 400 |
舊式 filters map | 精確主鍵比對——字串或字串清單;其他形狀 → 400 |
單欄 sort_by | 真正的主鍵(多欄 sort 原本就是) |
aggregate group_by | 逐筆分組,key 是解析得到的那個 id |
| aggregate 指標 | 只有 count/count_distinct;對 id 做 sum/avg/min/max → 400 |
q OR 搜尋 | 不再探測 id |
兩個值得動手的後果:
- 如果你的 client 曾經「用 id 篩選、拿到 0 筆、於是繞路」——把繞路拆掉;現在篩選比對得到了。
- 同一個修正也進了存量設定:rules 的
exists/not_existswhere 條件、rollup 與計算欄位的 filter、row policy、upsert 的 match-by-id,以前全部無聲比對不到,現在都是活的。在假設行為不變之前,先盤點所有提到id的存量設定;撰寫端則在文法撐不起語意的地方直接拒絕id(SCP 的 scalar leaf、no_overlap的欄位槽)。
日期時間邊界現在大聲失敗
date 與 datetime 儲存格是正規字串(YYYY-MM-DD、YYYY-MM-DD HH:MM)、以字典序比較,所以其他形狀的邊界以前會悄悄移動比較結果:帶秒數的 "2026-01-01 02:35:00" 對上儲存格 "2026-01-01 02:35",每個 eq/lte/gte 邊界都偏了;"2026/01/01" 什麼都比不到——而全部都回 200。現在 stored_filters 與 any_of 的邊界跟其他通道一樣先驗證:in 逐項驗(而且必須是清單——裸字串以前會被逐字元疊代),between 驗兩端與個數。非正規形狀在請求當下就是 400。
一個遷移注意:存了非正規邊界的 saved view 或 IaC spec,讀取時現在回 400,不再無聲回傳偏移的結果。大聲失敗正是這次修正的重點——修掉存下來的邊界,不要想辦法固定舊回應。真正的 created_at/updated_at 欄位是真 DATETIME 比較,秒數在那裡仍然合法。
OR 群組:any_of
any_of 是在伺服器端表達 OR 的唯一方式。同一群組內的 predicate 以 AND 合併,群組之間以 OR 合併,整個 any_of 區塊再與 filters、stored_filters、computed_filters、q 以 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 變成另一套語意。
相對日期視窗
三個運算子用「相對於現在」表達視窗,而不是寫死一個時間戳。它們的 value 是 RelativeDateValue 物件:
{
"column": "col_a1111111_1111_4111_8111_111111111111",
"op": "within_last",
"value": { "amount": 30, "unit": "days", "tz_offset_minutes": 480 }
}| 欄位 | 型別 | 限制 |
|---|---|---|
amount | integer | 1 到 730 |
unit | string | days 或 hours |
tz_offset_minutes | integer | −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 |
互補的邊界只會讓 datetime、created_at 與 updated_at 乾淨切開。date 欄不同:兩個邊界都會截斷成 YYYY-MM-DD,因此落在目前有效日曆日期的資料列,可能同時命中相鄰、以天為單位的 within_last 與 within_next 視窗。
必須記住的規則:
- 絕不會存下絕對時間。 Saved view 或 IaC 文件保存的是相對形式,每次 apply 重新解析;因此「最近 30 天」的 view 永遠是滾動視窗,plan hash 也不會每天飄移。
- 只能用在
date/datetime欄 — 以及真正的created_at/updated_at。其他型別是 400filter op '<op>' is only valid on date/datetime columns。存著日期字樣的 string 欄不會生效,要先改型別。 tz_offset_minutes只平移date欄的日界。對datetime、created_at、updated_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" 留給 datetime、created_at、updated_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_month、last_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_empty 與 is_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_null 與 is_empty 不是同義詞,也不能互換。is_null / is_not_null 看的是純量 cell 是否為空——key 不存在、明確的 JSON null、空字串 "" 在每一條通道上都算空;is_empty / is_not_empty 看的是 link 是否有連結、attachment 陣列是否為空。用錯欄位類別就是 400:對純量、json、multi_select 或真正的 created_at/updated_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_empty 或 is_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 語意:
eq、contains:陣列包含指定 option;in:包含清單中的任一 option。neq、not_contains:不含指定 option;not_in:不含清單中的所有 options。is_empty/is_not_empty:檢查陣列本身是否空;is_null/is_not_null:檢查 cell 是否為空(key 不存在、JSONnull或""),與 REST 同一條規則。
分析工具還有 starts_with、ends_with、like、not_in、between 等延伸運算子,而且它的 any_of 是「list of list」,不是 REST 的 {stored_filters: [...]} 外層。它也支援 within_last、within_next、older_than:query_records 與 aggregate_records 可用於 date/datetime 欄位及 created_at/updated_at,join_aggregate 則只接受 date/datetime 資料欄位。兩邊的 payload 仍不能互相複製;請依你正在呼叫的 reference schema 組 payload。值的形狀與精確型別閘門見 Agent 工具箱的滾動時間窗。
List 與 search 怎麼選
GET .../records 適合初次載入 grid:提供 skip、limit、sort_by、sort_order、建立日期 from/to 與可選的 expand_links,但沒有複合 predicate body。它沒有取得 any_of、多欄 sort、relative-date 或 presence 運算子 — 這四項只存在於 request body 的通道。
POST .../records/search 適合搜尋面板、進階篩選與計算欄條件。它使用 limit/offset,可合併 q、四條篩選通道與任一種排序形式。兩者都回 records、total 與含 schema/mapping 的 table,也都先套用同一份有效 ACL。
不要在前端先抓完整資料再過濾。這會讓分頁總數錯誤、漏套 filtered ACL,也會把 aggregate/export 變成另一套語意。
排序與分頁
排序有兩種形式,而且互斥:
- 單欄 —
sort_by(內部鍵)加sort_order(asc/desc)。它接受 stored scalar、id/created_at/updated_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 欄 | 可以 |
id、created_at、updated_at | 可以 |
sort_order(手動拖曳的列順序) | 只在 records/search 可用 — saved view 與 public-read query 會拒絕 |
| rollup/lookup/formula/link | 不行 — 計算欄只能用 sort_by 單欄排序 |
attachment、json、principal、user、social_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 apply | skip + limit |
records/search POST | offset + limit |
| 分析工具 | 1-based page + page_size |
Query-mode command(POST .../commands/{id}/query) | 不透明的 page.cursor + page.limit,回應含 next_cursor + total_available |
各 surface 的列數上限並不相同,沒有單一數字:
records/search與records/export的limit仍是 1..1000。- Query-mode command 的
page.limit是 1..100,預設 100。 - Public-read query 的
limit是 1..100、預設 50;publicGET /read/{token_id}/records的limitquery parameter 上限同樣是 100。 - 分析工具的
page_size上限是 100。
永遠以回應的 total 或 total_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}/restore 的 max + 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欄位,合法的指標只有count與count_distinct;對id做sum/avg/min/max是400。POST .../records/export接受與 search 相同的 legacy/stored/computed/q/any_of/單欄/多欄sort設定(包含同樣的any_of: []與排序互斥 422),串流輸出 CSV 或 XLSX。檔案只有可見列與可見欄。列數上限是在套用any_of與sort之後、開始輸出位元組之前評估的,因此匹配超過 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 保存 filters、stored_filters、computed_filters、any_of、q、sort_by、sort_order、sort,以及要顯示的 columns。查詢鍵仍遵守 search 規則;只有 columns 保存顯示名稱。分頁不會存進 view,而是在每次 apply 時以 skip/limit 傳入。
新欄位有兩條專屬規則:
- relative-date predicate 保持相對形式,每次 apply 才解析成絕對邊界。這就是滾動「最近 7 天」view 不需要有人定期改寫的原因。
- View 的
sort不能用sort_order當 key,即使records/search接受它:view 的系統欄集合只有id、created_at、updated_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,因此只能使用基礎十個運算子:between、within_last、within_next、older_than、is_empty、is_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 使用同一張表發送兩種請求。