查詢資料列:篩選、排序與檢視
自訂資料表提供的不只「列出全部」。同一套型別化 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 | 大於/大於等於/小於/小於等於 | 數字、日期等可排序的單一值(decimal 欄位則是小數字串) |
in | 落在集合內 | 非空陣列,最多 100 個純量 |
contains | 文字包含 | 單一字串,可用於 string 與 text 欄位;REST 的 stored_filters 與 any_of 也接受 select 欄位,而規則 when、rollup filter(columns.preview 則回 valid: false)與 aggregate 的 filters 對 select 使用 contains 會回 400。在 REST 的 stored_filters 與 any_of 中,integer、float、boolean、date 或 datetime 欄位會回 400 Unknown filter op: contains,decimal 欄位同樣以 400 拒絕 |
is_null / is_not_null | 是/不是 null | 省略 value |
請求 schema 會在任何東西執行之前先檢查每個運算元的形狀。只要 predicate 屬於請求 model 的一部分——records/search 與 records/export(stored_filters、any_of、computed_filters)、saved view 的 config、records/aggregate 的 filters,以及 REST 的 rollup filter——空的、超過 100 項的,或含有 null、清單或物件項目的 in 清單,以及與 is_null / is_not_null 一起送出的非 null value,都是 422,訊息例如 Value error, filter op "is_null" must not set a value。自己驗證 predicate 的 surface 則回應不同:ACL row policy 同樣是 422,規則或 trigger 的 when 與 SCP policy 的葉節點是 400(但 user、social_client 或 principal 欄位上的 trigger when 不限制 in 清單最多 100 項),public-read query 則是 400 invalid body: …。所有 predicate 在同一個 list 內通常以 AND 合併。欄位型別仍是最後限制:例如計算欄不一定支援 contains,link 也不是一般純量。在 decimal 欄位,以及結果為精確小數的計算欄上,每個運算元(包含每個 in 項目與 between 的兩個界線)都是像 "1200.50" 的純小數字串,不可有指數、+ 號或前導零;JSON 數字在型別化的查詢路徑、以及撰寫規則 when、SCP policy 或 rollup filter 時都是 400。舊版 filters map 只有 records.search 與 records.export 會檢查值,因此在套用儲存檢視或公開讀取查詢時,那裡的 JSON 數字會比對不到任何列。trigger 的 when 與 row policy 在儲存時不檢查運算元型別,因此兩處都請寫成小數字串。trigger when 中的 JSON 數字永遠比對不到;在 row policy 中,它會讓單筆讀取看不到該資料列(404),而 REST 的列表、搜尋或匯出背後的 SQL 編譯會以 decimal filter value must be a decimal string 失敗,REST 只會回一個通用的 500。
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 的列。有兩個刻意的例外:json 欄位只有缺 key 或 JSON null 才算空(JSON "" 是真實的文件);agent 工具組對 multi_select 的否定運算(neq、not_contains、not_in)會把空白 cell 視為「不包含」。舊版 filters map 沒有 presence 閘門;若空白 cell 絕不能命中,請使用 stored_filters。
運算子詞彙依 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 是否空,或 interval cell 是否空 | 省略 value |
overlaps | interval cell 與半開查詢區間重疊 | 剛好兩個標準 YYYY-MM-DD HH:MM datetime,[start, end],且 start < end |
contains_point | interval cell 包含該時間點 | 一個標準 YYYY-MM-DD HH:MM datetime |
name_eq / name_contains | user / social_client cell 所指人員的顯示名稱等於/包含該文字 | 一個 1–64 字元的顯示名稱字串(name_contains 至少 2 字元) |
座標欄另外有兩個僅限查詢的運算子 within 與 within_any,各有自己的具型別 value,並搭配 sort[].distance_from 排序;自 backend 5.21.0 起,說明見座標查詢。
請求 schema 會在解析任何欄位之前,先依 op 檢查每個 predicate 的 value。在 search 與 export 的 stored_filters、any_of 群組與 computed_filters、saved view 的 config、aggregate 的 filters,以及 rollup 的 filter 中,不符合時是 422,其 msg 是 Value error, 後接下列文字之一(public-read query 以相同文字回 400 invalid body: …):in 需要非空清單(filter op "in" requires a non-empty list value),最多 100 項(filter op "in" value list exceeds max 100 entries),且沒有 null、清單或物件項目(filter op "in" list entries must be scalar values (string/number/boolean));between 需要恰好兩個純量界線(filter op "between" requires a [low, high] list of exactly two values、filter op "between" bounds must be scalar values);overlaps 需要兩個正規 datetime 且 start < end(filter op "overlaps" requires a [start, end] list of exactly two values、filter op "overlaps" bounds must be canonical datetimes (YYYY-MM-DD HH:MM)、filter op "overlaps" bounds must satisfy start < end);contains_point 需要一個正規 datetime(filter op "contains_point" requires a canonical datetime value (YYYY-MM-DD HH:MM));is_null、is_not_null、is_empty 與 is_not_empty 必須省略 value(filter op "<op>" must not set a value);其他所有運算子需要一個非 null 的純量(filter op "<op>" requires a value、filter op "<op>" requires a scalar value)。relative-date 運算子的 value 若不是合法的 RelativeDateValue,同樣是 422。
哪個 surface 接受哪一套不是風格偏好,而是雙重把關的規則:
- Query surfaces(額外運算子可用):
POST .../records/search、POST .../records/export、saved view 的config,以及 public-read 的POST /read/{token_id}/query。name_eq/name_contains屬於請求範圍:只有私有records/search的 body 會解析它們;export、public-read query,以及 publish session 的replace_filter回400,saved view 則在儲存時以400拒絕。 - Config surfaces(只有基礎十個):規則/trigger 的
when、ACL row policy(read_filter/edit_filter)、SCP policy、rollupfilter,以及POST .../records/aggregate的filters。
Config surface 會在撰寫設定時拒絕 query-only 運算子:以基礎詞彙型別化的 body(columns.create 或 tables.create 中的 rollup filter,或 records/aggregate 的 filters)與 ACL row policy 回 422,規則或 trigger 的 when 與 SCP policy 則回 400(見 ACL filtered 與規則 when)。仍帶著這類運算子的已存設定,會在求值時以 filter op '<op>' is not permitted on this surface 拒絕,並且 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 值做不分大小寫的部分比對,含%或_的值會被當成原始LIKEpattern;其他純量做精確比對。decimal的值必須是符合欄位 precision、scale 與正負號的純小數字串,比對前會先正規化到欄位 scale("12.5"會比對到12.50),JSON 數字或""是 400。multi_select傳一個 option 字串時做陣列 membership。interval與attachment欄位不能在這裡篩選;指名其中之一是 400。在同時有 link、rollup、lookup 或 formula 欄的表上,search、export 與 saved-view apply 的 detail 會指名該欄:Filtering on attachment column '<key>' is not supported或Filtering on interval column '<key>' is not supported。其他情況下(public-read query 則一律如此),attachment key 回Filtering on attachment columns is not supported,interval key 回Filtering interval columns via 'filters' is not supported — use stored_filters (overlaps/contains_point/is_empty/is_not_empty);但records.search與records.export會在值檢查階段更早拒絕非空的 interval 值,訊息為Filter validation errors: Filtering interval column '<key>' via 'filters' is not supported — use stored_filters (overlaps/contains_point/is_empty/is_not_empty)。stored_filters是 stored scalar、interval、link 與 attachment 欄的型別化 predicate,最多 20 條。除了基礎運算子,它還接受between、三個 relative-date 運算子、presence 運算子、interval 運算子與顯示名稱運算子;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。 - Interval 欄只接受
overlaps、contains_point、is_empty與is_not_empty。其他一律 400interval columns support only overlaps/contains_point/is_empty/is_not_empty (got '<op>'),而對純量或json欄使用overlaps/contains_point是 400filter op '<op>' is only valid on interval columns。格式不正確的運算元會先在請求 schema 階段以422失敗:overlaps需要恰好兩個正規的YYYY-MM-DD HH:MM界線且start < end,contains_point需要一個正規 datetime(例如Value error, filter op "overlaps" bounds must satisfy start < end;public-read query 回400 invalid body: …)。見 Interval。 user/social_client欄在即時的私有 search 中也接受name_eq/name_contains。伺服器會把名稱解析成 id(符合超過 50 筆是 400),並在回應加上resolved_principals回執。空白、數字或布林值的 needle、超過 64 個字元的 needle,或(對name_contains)短於 2 個字元的 needle 是 400,在任何其他型別的欄位上使用這兩個運算子也是;principal型別欄兩者都會拒絕。請求中每個不同的 needle 都會計入每位使用者 30 次名稱解析的額度,計數只有在 300 秒內沒有任何解析嘗試後才會重設(被拒絕的嘗試也算)。超過額度,或限流器不可用時,搜尋目前會失敗並回一個通用的500 Internal Server Error,而不是 4xx。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 也解不開,只有把表變小才行。帶有fallback的 lookup,以及引用它的 formula,都只能在求值時計算(eval-only),在這裡與計算欄sort_by都是 400。結果為精確小數的計算欄接受小數字串運算元;當查詢範圍內有一列無法精確求值時,整個請求會以 400ct.numeric_overflow: computed decimal cannot be evaluated exactly(或ct.numeric_invalid: computed decimal cannot be evaluated exactly)失敗,而不是把值捨入,即使那一列本來不會被回傳也一樣。這類欄位上的計算欄sort_by也會以同樣方式失敗。any_of是 OR 群組清單,見下文〈OR 群組:any_of〉。q在呼叫者可見的所有 string/text 欄位做不分大小寫的全域 substring OR 搜尋,再與其他通道 AND 合併。它在 search、export 與 saved view 的config上最多接受 200 個字元;更長的q是422。隱藏欄位不參與——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,不只頂層通道。非管理者引用看不到的欄位時,在每一條通道上得到的回應,都和「欄位真的不存在」完全相同:stored_filters、any_of 與 computed_filters 是 Column '<name>' does not exist in table schema,filters、sort_by 與 sort 是 Filter column '<col>' … / Sort column '<col>' does not exist in table schema. Valid columns: […],其中的清單只列出呼叫者有權讀取的欄位。自後端 5.21.0 起,各通道不再依原因給出不同回應:以前 hidden 的 filters、sort_by 或 sort key 回的是較短的 Column '<name>' … 形式,不存在的 filters 或 sort_by key 則附上列出每一個 schema 鍵值的 Valid columns 清單;不存在的 computed_filters 欄位以前則是 computed filter column '<name>' not found in table schema。前端錯誤文案因此不能斷言「沒有這個欄位」,也不能依賴措辭做分支。
系統 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 | 精確主鍵比對——search 與 export 只接受一個非空的 id 字串(傳清單、數字、布林值或物件,會在它們的值檢查階段失敗:Filter validation errors: Filter value for column 'id' must be a string);套用的 saved view 也接受非空的 id 字串清單。search 與 export 上的空字串或 null,以及 view 上的任何其他形狀,都是 400 Filtering by 'id' requires a record id string or a non-empty list of record id strings |
單欄 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 驗兩端與個數。非正規的日期或 datetime 界線在請求當下是 400;裸字串的 in 值,或不是恰好兩個界線的 between,則會更早被請求 schema 以 422 拒絕。
一個遷移注意:存了非正規邊界的 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。其他型別是 400:純量或json欄回filter op '<op>' is only valid on date/datetime columns,attachment 欄回attachment columns cannot be filtered,link、interval、principal 與id欄則回它們自己的運算子清單訊息。存著日期字樣的 string 欄不會生效,要先改型別。 tz_offset_minutes只平移date欄的日界。對datetime、created_at、updated_at而言視窗是純粹的滾動 UTC 區間,這個 offset 沒有作用 — 台北的 client 在 datetime 欄傳480不會有任何位移。
在 date 欄,unit: "hours" 會被截成整天。視窗若落在(套用 offset 後的時鐘的)同一個日曆日內,兩個邊界都會截成同一個日期,於是編譯成 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 的陣列,三者都算空。 - 在 interval 欄上,它們的意思是缺 key 或明確的 JSON
null。
兩者都必須省略 value;帶了值會得到 422,訊息為 Value error, filter op "<op>" must not set a value(公開讀取查詢則回 400 invalid body: …,內含相同文字)。
is_null 與 is_empty 不是同義詞,也不能互換。is_null / is_not_null 看的是純量 cell 是否為空——key 不存在、明確的 JSON null、空字串 "" 在每一條通道上都算空(json 欄位只有缺 key 或 JSON null 才算空);is_empty / is_not_empty 看的是 link 是否有連結、attachment 陣列是否為空,或 interval cell 是否為空。用錯欄位類別就是 400:對純量、json、multi_select 或真正的 created_at/updated_at 使用 presence 運算子會回 filter op '<op>' is only valid on link or attachment columns(這句措辭早於 interval 支援)。
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 與 interval 欄位。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:檢查陣列本身是否空(缺 key、JSONnull或[]都算空);is_null/is_not_null:只檢查 cell 是否缺 key 或為 JSONnull。與 REST 不同,neq、not_contains與not_in也會比對到空白 cell。
分析工具還有 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 工具箱的滾動時間窗。
query_records 驗證的是那個外層更嚴格、只限查詢的版本。帶有未知 key 的條件或群組會被拒絕,所以 REST 風格的 op key 在那裡是錯誤,而不是無聲的 eq;群組至少需要一個條件;is_null、is_not_null、is_empty 與 is_not_empty 拒絕任何非 null 的 value。它也是唯一會篩選與排序 interval 欄位的分析工具,filters 與 any_of 皆然,並經由 REST stored_filters 背後的同一個編譯器:overlaps 接受獨立的 [start, end] 清單,內含正規的 YYYY-MM-DD HH:MM datetime 且 start < end,contains_point 接受一個正規 datetime,is_empty / is_not_empty 不帶值,且只把缺少 key 或 JSON null 算作空。interval 欄位上的任何其他運算子會被拒絕,overlaps 與 contains_point 用在任何其他欄位也是。sort_column 與多 key 的 sort 依 interval 的 start 界線排序,並以 record id 升冪處理同值。aggregate_records、join_aggregate 與 correlation 維持原本的外層:它們拒絕 interval 篩選,仍會忽略未知 key(op key 會被丟掉,條件照 eq 執行),也仍會忽略與 presence 運算子一起送出的任何 value。見 Interval。
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、has_more 與含 schema/mapping 的 table(預設的 offset 模式還會多一個精確的 total),也都先套用同一份有效 ACL。兩者也都可以用 cursor 分頁,這種方式不做筆數統計;資料量大的表請優先使用。
不要在前端先抓完整資料再過濾。這會讓分頁總數錯誤、可能偏離 filtered 權限的語意,也會把 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 與decimalkey 是數值排序而不是字典序(decimal為精確排序),intervalkey 則依它的start界線排序。第 4 個 key 在資料列搜尋、匯出與已儲存檢視都是422;公開讀取的 query 上限相同,自後端 5.22.0 起回400invalid body(以前 4–10 個 key 會回500)。
在 records.search、records.export 或 saved view 同時送出 sort 與 sort_by 是 422,訊息為 Value error, Provide either `sort` (multi-column) or `sort_by`/`sort_order`, not both.。public-read query 是例外:兩者都接受,非空的 sort 優先。UI 若在 state 保留了預設 sort_by,加入多欄排序前必須清掉它;單獨殘留的 sort_order 會被接受並忽略。在 search、export 與 saved view 中,sort_order 必須是 asc 或 desc:其他任何值(包含 null)都是 422,在 search 與 export 上訊息為 Value error, Sort order must be 'asc' or 'desc'(數字或布林值等非字串值在那裡則是 Input should be a valid string),在 saved view 中則為 Input should be 'asc' or 'desc'。GET .../records 與 GET .../view 的 sort_order 查詢參數對其他值回 422 String should match pattern '^(asc|desc)$';public-read query 則接受 null,但對其他任何字串回 400 invalid body: …。
多欄排序可用的 key 集合刻意比 sort_by 更窄:
| Key | 可用於 sort? |
|---|---|
stored scalar 欄(含 decimal) | 可以 |
interval | 可以——依 start 界線 |
id、created_at、updated_at | 可以 |
sort_order(手動拖曳的列順序) | 在 records/search 與 records/export 可用 — 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;在 search、export 與 saved view apply 上,訊息後面還會接 . Valid columns: […],只列出呼叫者可讀的欄位。這是行為變更:以前傳顯示名稱或打錯字時會退回 string 型別、對不存在的 JSON key 編譯,然後回 HTTP 200 但排序全為 NULL。sort[].column 必須是內部 col_<hex> key。被封鎖的欄位型別同樣是 400,但訊息措辭依通道而異。對 attachment、json 或 principal 欄,sort_by 與 chatroom records/search 通道回不含欄名的版本(Sorting on attachment columns is not supported);department/company 的 records/search,以及所有範圍的 records/export 與 saved-view apply,對 sort key 則回含欄名的版本(Sorting on attachment column '<col>' is not supported),但只有當該表同時有 link、rollup、lookup 或 formula 欄時才如此。把 link、rollup、lookup 或 formula 欄放進 sort 一律會被指名拒絕:那些通道回 Sorting on link/rollup/lookup/formula column '<col>' is not supported,chatroom records/search 通道則回 Sorting on computed column '<col>' via multi-column sort is not supported — use sort_by。所以不要從 detail 裡剖析欄位名稱。
空的 sort: [] 仍算作送了 sort。單獨送出時代表預設順序(手動 sort_order,再依最新 created_at,最後依 id),在 public-read query 上則改為退回 view 已存的排序(若有);與 sort_by 併用時,在 search、export 與 saved view 上是同一個 422,只有 public-read query 會忽略它並使用 sort_by。any_of: [] 與空的 any_of 群組則一律被拒絕。
常見的分頁欄位命名如下:
| Surface | 分頁欄位 |
|---|---|
records GET、saved-view apply | skip + limit,或 page_mode=cursor + cursor + limit(cursor 分頁) |
records/search POST | offset + limit,或 page_mode: "cursor" + cursor + limit |
Agent 讀取資料列(custom_tables_query_records、custom_tables_find_records) | 不透明的 cursor + page_size(find 是 limit),回應含 has_more + next_cursor,沒有總數(細節) |
MCP records_query、workspace ct.query 與 Python SDK | page_mode: "cursor" + cursor + limit;已棄用的寫法是 MCP 的 offset + limit,以及 ct.query 的 page + limit(細節) |
| 其他分析工具(aggregate、traverse) | 1-based page + page_size |
Query-mode command(POST .../commands/{id}/query) | 不透明的 page.cursor + page.limit,回應含 next_cursor + total_available |
各 surface 的列數上限並不相同,沒有單一數字:
records/search、GET .../records與 saved-view apply 的limit是 1..1000(預設 100),它們的offset/skip預設為 0;limit超出該範圍或offset/skip為負數是422。在 offset 模式下,翻頁翻過最後一筆匹配不是錯誤:回應是200,records為空清單,並帶真正的total。records/export不接受limit或offset:它會串流整個篩選後的集合,且匹配超過 100,000 列時會以400拒絕。- 在有 link 或計算欄的表上,
expand_links=true會拒絕回傳超過 100 列的一頁:400expand_links is only supported with limit <= 100; reduce limit or paginate。這適用於GET .../records、records/search、帶 hydrate 的GET .../view,以及 saved-view apply。 - 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。
cursor 模式以回應的 has_more 與 next_cursor 畫分頁;offset 模式則以回應的 total(分組彙總工具是 total_pages)。不論哪一種,都不要用「這頁是否剛好滿」猜還有沒有下一頁。換頁時也要保持排序條件不變,否則列可能重複或跳頁 — 使用多欄 sort 時,補在最後的 record-id tiebreaker 已保證兩次相同請求不會把等值列重新洗牌。
cursor 分頁與筆數
offset 分頁要伺服器數完所有符合的資料列、並跳過這一頁之前的每一列,所以一頁的成本會隨資料表變大。cursor page(cursor 分頁)只讀它要回傳的那一頁,外加多讀的一列。GET .../records、POST .../records/search、saved view 的讀取(GET 與 POST .../views/{view_id}/records),以及整合檢視的 GET .../tables/{table_id}/view,在三種範圍都接受它。逐步做法見效能指南。
| 欄位 | 放在哪裡 | 契約 |
|---|---|---|
page_mode | 所有 GET,以及已儲存檢視的 POST …/views/{view_id}/records(沒有 body),放在 query string;POST …/records/search 放在 body | offset(預設)或 cursor。 |
cursor | 同上 | 上一頁的 next_cursor。只要帶了它,就會自動採用 cursor 模式。最長 8,192 個字元:超過的值會被請求驗證以 422 拒絕。 |
limit | 和以前相同 | 1–1,000,預設 100。每一頁可以不同。cursor 模式會忽略 skip 與 offset。 |
分頁回應多了 has_more 與 next_cursor(整合檢視的回應也一樣):
| 欄位 | offset 模式(預設) | cursor 模式 |
|---|---|---|
has_more | 這一頁之後還有資料列時為 true | 還有下一頁時為 true,靠多讀一列判斷 |
next_cursor | null | 下一頁的 cursor;最後一頁與空的一頁為 null |
total | 符合條件的精確筆數(已棄用) | null:不做筆數統計 |
table.record_count | 和以前相同 | null |
total 在 OpenAPI 文件中已標為棄用。目前在 offset 模式仍是精確值,等前端遷移完成後(目前沒有訂出日期),會連同 offset 與 skip 一起移除;在那之前,沒有帶 page_mode 的請求行為不變。
cursor 只在同樣的條件下有效。 它是由伺服器簽章、內容不透明的字串,綁定這幾項:資料表、呼叫者、整個查詢(篩選、q、排序)、資料表欄位結構、用到的 saved view,以及呼叫者當下讀得到的資料範圍(列權限、欄權限與 SCP 的結果)。其中任何一項改變,cursor 就會以 400 ct.invalid_cursor 失效,呼叫端要從第一頁重新開始。cursor 只是接續讀取的記號,不是授權:每一頁都會先重建呼叫者當下的存取範圍,再檢查綁定,所以拿 cursor 讀不到呼叫者已經看不到的資料列。
排序的成本。 依 id、created_at、updated_at、手動排列順序或儲存的數字欄位(integer、float、decimal)排序時,是從上一頁最後一列往後接著讀,翻得很深的頁面,成本和第一頁差不多。依其他儲存欄位(文字、日期、日期時間、布林、單選、多選)排序也可以搭配 cursor,但 cursor 裡會帶一個內部的略過筆數,伺服器每次都得先跳過前面的資料列,所以越後面的頁面成本越高,而且在讀者目前位置之前新增或刪除一列,可能讓某一頁重複或漏掉一列。依計算欄位(rollup、lookup、formula)排序沿用原有的限制:資料表的現行資料列超過 50,000 筆時會被 400 拒絕,用不用 cursor 都一樣。
讀取時間上限。 分頁與筆數的查詢都有時間上限,預設 15 秒(這是部署設定 CT_READ_QUERY_TIMEOUT_MS,範圍 1 ms 到 60 秒;超出範圍的值會退回 15 秒)。每個請求只有一份時間額度,由它的筆數查詢與讀取共用;讀取超過上限時,回 400,detail.code 為 ct.query_timeout,並附上實際設定的 limit_ms。offset 分頁同樣受這個上限約束。座標欄位的篩選沿用自己較短的上限。
| 狀態 | detail | 何時發生 |
|---|---|---|
400 | {"code": "ct.invalid_cursor"} | cursor 被改過,或和資料表、查詢、呼叫者或呼叫者當下的權限對不上 |
400 | {"code": "ct.query_timeout", "limit_ms": 15000} | 這次讀取超過時間上限 |
503 | {"code": "ct.cursor_unavailable"} | 伺服器沒有簽章用的密鑰,無法簽發或檢查 cursor;offset 模式仍可使用 |
其他的驗證錯誤(不存在的排序欄位、不合法的篩選值)維持原本的回應。
計算筆數:POST .../records/count
cursor 分頁不計算筆數。畫面需要顯示數字時,把 records/search 的 body 送到 POST .../tables/{table_id}/records/count(公司、部門與聊天室範圍都有),它回 {"count": <n>}:
- 這個筆數是精確值,涵蓋目前可讀取的全部符合資料列:套用和搜尋相同的租戶、列 ACL、欄 ACL 與 SCP 規則,所以它等於該呼叫者實際翻得到的列數。同一張表,不同的呼叫者可能得到不同的筆數。
limit、offset、page_mode、cursor與排序欄位可以帶,但會被忽略,所以呼叫端可以直接沿用搜尋的 body。搜尋沒有檢視 ID 這個欄位;要計算某個 saved view 的筆數,請送出該檢視的篩選條件。- 它受上面的讀取時間上限約束(
400 ct.query_timeout),而且要逐筆數過所有符合的資料列,所以資料表非常大時可能很慢。顯示數字時算一次就好,不要每一頁都算,也不要拿它判斷有沒有下一頁(那是has_more的工作)。 - 它的權限檢查和搜尋用的是同一道讀取閘門,而且筆數的開放範圍跟著搜尋走:使用者的 JWT 可以呼叫它,整合 API key(
third_party、specific_chatroom或acquisition)則和搜尋、列表一樣被拒絕,回405(見各介面接受哪種憑證)。筆數會接受完整的搜尋 body,如果不能讀取資料列的 key 還能算筆數,就能用帶篩選條件的筆數反覆探測值。
Agent 工具箱、MCP 與 workspace SDK
助理的工具、MCP 伺服器與 workspace SDK 讀取資料列時遵循同一套契約:不做筆數統計的 cursor 分頁,加上另外的精確筆數,而且只在有人要數字時才去要。各個 surface 的參數見 Agent 工具箱頁面。
| Surface | 分頁 | 精確筆數 |
|---|---|---|
Agent custom_tables_query_records | 送入 cursor,回傳 has_more 與 next_cursor;沒有 total 或 total_pages | operation: "count" |
Agent custom_tables_find_records | 送入 cursor,回傳 has_more 與 next_cursor;沒有 total | 無 |
MCP records_query | page_mode: "cursor",之後送 cursor;cursor 模式的 total 是 null,預設的 offset 模式仍保留已棄用的精確 total | records_count 工具 |
Workspace ct.query、teamsync data ct query 與 teamsync_ct_query | page_mode: "cursor",之後送 cursor;預設仍是 offset | operation: "count" |
Python SDK tables.query 與 tables.count | 預設就是 cursor 模式 | tables.count |
這些 surface 的 cursor 和 REST 一樣綁定呼叫者、資料表欄位結構、查詢與排序,以及呼叫者當下的權限與隱藏欄位,在房間裡執行的讀取(agent 與 workspace 通道,以及聊天室的資料表)另外還綁定該房間,所以拿另一個房間的 cursor、或在上述任何一項改變之前產生的 cursor,都會回 ct.invalid_cursor,必須從第一頁重新開始。Agent 工具與 MCP 沿用同樣穩定的代碼(ct.invalid_cursor、ct.query_timeout、ct.cursor_unavailable);workspace 通道則分別回 invalid_cursor、query_timeout 與 data_unavailable(最後一種在 ct.query 回 not_found)。拿 REST 讀一遍和 agent 讀一遍相比時,要記得一個差異:agent 的預設順序把沒有手動位置的資料列排在最前面,REST 的預設順序則排在最後。要在 REST 上讀到 agent 的順序,請送 sort: [{"column": "sort_order", "order": "asc"}]。within_last 這類滾動時間窗每次請求都會重新編譯,所以用到它的長時間讀取,可能以 ct.invalid_cursor 結束;需要穩定讀完時,請改用絕對日期。
把列表頁從 offset 改成 cursor
- 在請求加上
page_mode=cursor(搜尋放在 body 裡),保留limit,拿掉skip與offset。 - 用一個
cursor變數記住讀到哪裡:第一頁沒有,之後是剛收到那一頁的next_cursor;has_more為false就停。要讓使用者往回翻,請在畫面自己的狀態裡保留用過的 cursor。cursor 要送回發出它的同一條路由。 - 不要再讀
total與table.record_count,兩者都是null。頁碼改成「載入更多」或上一頁/下一頁。要顯示數字的地方,每次搜尋呼叫一次records/count。 - 篩選、搜尋文字、排序或檢視一有變動,就丟掉 cursor,請求第一頁。收到
400 ct.invalid_cursor也一樣處理。 - 一次遷移一個列表:沒有帶
page_mode的請求,行為仍和以前相同。
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。group_by接受 0–2 個欄位、metrics為 1–5 個(超出這些數量時請求是422);超過 1,000 個 group 是 400。除了儲存的純量欄位外,group_by也接受 cardinality-one的 link 欄位,以 live 目標紀錄 id 作為 key;未連結的列、已軟刪除的目標,以及被 channel scope 隱藏的目標共用nullgroup。multi_select、json、attachment、interval 或計算欄不論當group_bykey 或 metric 欄位都是 400,cardinality-many的 link 當 key、任何 link 當 metric 欄位也是。sum與avg需要integer、float或decimal欄位,min/max也接受date與datetime;其他型別是 400,訊息會指名欄位與它的型別(例如target_column '<column>' is type date — sum requires integer/float/decimal,後面接該表符合資格的欄位)。Group key 與 metric 標籤("<agg>::<column>")都用顯示名稱。對欄位做count只計非空 cell(缺 key、JSONnull與""都排除),"*"計算列數,count_distinct計算不重複的有值項目;agent Analyst 的aggregate_records計數使用同一套閘門(join_aggregate則否)。整數與浮點結果是 JSON 數字。在decimal欄位上,group key 與sum/min/max是以欄位 scale 表示的精確小數字串。avg必須帶result_scale(0–18)與rounding_mode(half_even、half_up、down、floor、ceiling),並回傳以該 scale 表示的精確字串;缺少時是 400decimal avg requires result_scale and rounding_mode,把這一對用在其他 metric 則會被拒絕。POST .../records/export接受與 search 相同的 legacy/stored/computed/q/any_of/單欄/多欄sort設定(包含同樣的any_of: []與排序互斥 422),串流輸出 CSV 或 XLSX。它不接受limit、offset、editable或pending_approval(422),而name_eq/name_contains是 400。檔案只有可見列與可見欄。列數上限是在套用any_of與sort之後、開始輸出位元組之前評估的,因此匹配超過 100,000 列時要先縮小篩選。檔案內容與大小、時間上限見匯出檔案。
若需求是「先找到訂單,再顯示 link 指向的客戶」,先 search 訂單,再收集 target IDs 做一次 lookup;不要逐列發 request。
匯出檔案
匯出會先把整份檔案完整產生,才送出第一個位元組,所以下列每一種拒絕都是一般的 JSON 錯誤,200 也絕不會是被截斷的檔案。在 MySQL 上,產生檔案時所有分頁都在同一個 repeatable-read 交易內讀取。匯出的工作量有上限,每種上限各有自己的回應(位元組上限與時間預算自後端 5.21.0 起才有):
| 上限 | 回應 |
|---|---|
| 符合的列數超過 100,000 | 400,detail 是純文字:Export exceeds the 100000-row cap (<n> rows match); narrow your filters and retry. |
| 輸出超過位元組上限(預設 512 MiB;XLSX 以 cell 的 UTF-8 文字與完成的檔案計算) | 413 {"detail":[{"code":"ct.export_too_large","detail":"Export exceeds its byte limit; narrow your filter and retry."}]} |
| 產生時間超過時間預算(預設 60 秒) | 422 {"detail":[{"code":"ct.export_time_budget_exceeded","detail":"Export exceeded its server render time budget; narrow your filter and retry."}]} |
兩個具型別的回應是 {code, detail} 物件組成的陣列,和列數上限的純文字不同。422 與請求驗證錯誤共用 {"detail":[…]} 外層(驗證錯誤的項目帶 type、loc、msg),因此請依 code 分支。部署端可以調整位元組上限與時間預算,但不會超過 1 GiB 與 90 秒,所以不要把預設值寫死。三者的處理方式相同:縮小篩選、拆成幾次篩選條件互不重疊的匯出,或改用 records/search 分頁取得資料列,而不是要求檔案;重送完全相同的請求,在列數上限與位元組上限上會以同樣方式失敗;時間預算則取決於伺服器負載,重試可能成功,但不要依賴它。關閉連線會取消正在進行的產生作業。
檔案內容遵循下列規則(CSV 公式標記與 XLSX 純文字保證是 5.21.0 起新增的):
- CSV 是不含位元組順序標記(BOM)的 UTF-8,每筆紀錄以 CRLF 結尾。試算表可能當成公式執行的標頭或文字 cell,會在前面加上撇號與零寬空白(
'後接 U+200B),並加上引號。適用於以=、+、-、@(或全形的=、+、-、@)開頭的文字(開頭可先有空白字元)、以撇號開頭的文字,以及以控制字元開頭的文字,所以開頭的 tab 或換行也會被標記。真正的數字,或integer、float、decimal、formula、rollup、lookup與座標軸 cell 中如-12.50的純數字字面值,維持原本的位元組;stringcell 裡的相同文字會被標記。CSV 檔案匯入會從 cell 與標頭移除剛好這兩個字元的前綴,因此匯出檔再匯入可還原原本的文字;程式讀取匯出檔時也應去掉同樣的前綴。MCP 的records_export工具產生的是同一種檔案。 - XLSX 把每個 cell(含標頭)都寫成純文字:
=1+1或#N/A這類值仍是文字,不會變成公式或錯誤 cell。XML 無法承載的字元(U+0000 至 U+001F 中 tab、換行與 carriage return 以外者,加上 U+FFFE 與 U+FFFF)會從 cell 中丟棄,而不是讓整個匯出失敗。工作表以資料表名稱命名:控制字元會被移除,[、]、:、*、?、/與\變成_,頭尾的撇號會被去掉,名稱截斷為 31 個 UTF-16 code unit,空名稱或History(不分大小寫)則改為Sheet1。
唯讀的 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,以及版面 kind(list、matrix 或 timeline)與其子設定。查詢鍵遵守 search 規則,因此請保存內部鍵。儲存時的檢查也會接受那裡的顯示名稱,但 apply 只會轉換指向 rollup、lookup 或 formula 欄的 sort_by:stored_filters、any_of 或 sort 的引用會因此回 400,指向儲存欄的舊版 filters key 會被無聲跳過(在有計算欄的表上指向 attachment 或 interval 欄的 key 則改回 400;自後端 5.21.0 起,隱藏於你的欄位會被當成不存在的 key 處理,而不是被拒絕),而指向儲存欄的 sort_by 會退回 created_at 順序。columns 保存顯示名稱,computed_filters 保留你送出的名稱,版面的欄位引用則以內部鍵保存。分頁不會存進 view,而是在每次 apply 時以 skip/limit 傳入。config 的上限與 search body 相同——最多 20 個 stored_filters、3 個 computed_filters、10 個各含 1–10 個 predicate 的 any_of 群組、3 個 sort key,以及 200 個字元的 q——另外最多 200 個 columns。建立或更新 config 時超過任一上限、送出空的 any_of,或同時送 sort 與 sort_by,都是 422;model 沒有宣告的 key 也是:沒有隱藏欄清單或分組欄位,僅限請求的 editable、pending_approval、limit 與 offset 也同樣被拒。仍帶有未知 key 的舊版已存 config 會在回應中省略該 key,apply 會忽略它。
新欄位有兩條專屬規則:
- 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 裡的欄位引用。在 stored_filters、any_of、computed_filters 或 sort 中引用對套用者隱藏的欄位,apply 會以 400 Column '<name>' does not exist in table schema 失敗(sort key 則是 Sort column '<name>' does not exist in table schema. Valid columns: […]),與 stored_filters、any_of、computed_filters 或 sort 引用已不存在的欄位得到的 400 相同。自後端 5.21.0 起,舊版 filters 或 sort_by 中的隱藏欄位不再讓 apply 失敗:它會被當成已不存在的 key 處理,因此無法利用 view 分辨隱藏的欄位與已刪除的欄位。自後端 5.22.0 起,顯示名稱為 created_at、updated_at、created_by 或 created_by_client 的隱藏欄位,會被當成沒有該欄位的資料表處理:使用這些名稱的舊版 filters key,或 created_at、updated_at 的 sort_by,指向紀錄本身的欄位(created_by 或 created_by_client 的 sort_by 沒有可排序的紀錄欄位,會退回依建立順序排序,與沒有該欄位的資料表相同);5.22.0 之前這個名稱會被換成沒有任何紀錄擁有的 key,所以排序落回 record id 順序,而 created_at、updated_at 或 created_by 的 filter 什麼都比對不到(指到這種欄位的多欄位 sort key 仍會讓 apply 以上述 400 失敗)。過時的舊版 filters key 只會什麼都比對不到,過時的 sort_by 會無聲地改變順序(已刪除的欄位在每一列都讀成 null,結果落回 record id 順序),而對呼叫者隱藏或已不再指向任何欄位的 columns 項目會被跳過,所以 columns 項目全都過時的 view,回傳的紀錄 data 物件是空的。刪除或改名欄位永遠不會改寫或阻擋已存的 saved view。以內部鍵引用的欄位在改名後仍然有效,但存在 columns、computed_filters 或計算欄 sort_by 中的顯示名稱會失效:computed_filters 項目會以 400 失敗,sort_by 會退回 created_at 順序,改名後的欄位會從紀錄中消失。版面引用以內部鍵保存,並會跟著改名;被刪除的版面欄位會以原始內部鍵回傳。要修復 view,請用完整的取代 config 重新儲存;只改名或分享它的更新不會重新驗證已存的 config。共享 view 不會凍結結果,也不會讓建立者可見的隱藏欄漏給其他讀者。GET 與 POST apply 只是兩個相容入口,都套用已保存的 config,不接受臨時覆寫 body。
ACL filtered 與規則 when 的共用語意
列級 ACL 的 can_read: "filtered"/can_edit: "filtered" 也使用相同 predicate 葉節點,放在一棵布林樹裡:非空的 and / or 群組、not,以及一跳的 link_target 葉節點,深度最多 5、predicate 最多 24 條。下面的傳統 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、relative-date、presence、interval 與顯示名稱運算子)在撰寫 policy、規則、SCP policy 或 rollup filter 時就會被拒絕,並由驗證器指名該運算子(例如 rollup filter 會回 filter[0] has invalid op 'between' (valid: eq, neq, …),而 REST 的 rollup body 會先以 422 通不過 schema 驗證)。狀態碼依 surface 而異:規則或 trigger 的 when 回 400,文字同樣是那句 filter[0] …(不論出錯的是哪一個 predicate,索引一律是 0;json 欄位,以及 trigger 中的 user、social_client 或 principal 欄位,則改回報 when column '<column>' …),SCP policy 的葉節點回 400 <path>: invalid scalar op 'between'. valid ops: …,ACL row policy 回 422 row policy <path> has invalid op 'between' (valid: …),records/aggregate 的 filters 項目則和 REST rollup body 一樣是 schema 422。唯一的例外是 SCP 的 link 成員葉節點,它另外保留 is_empty 與 is_not_empty。若只限查詢的運算子仍從已存的 policy、SCP 或 rollup 設定進到 SQL 編譯器,求值時會以 filter op '<op>' is not permitted on this surface 拒絕;規則與 trigger 的 when predicate 則在記憶體內求值,基礎十個以外的運算子永遠比對不到。所有 surface 共用 op/null/type 語意,但外層 payload、命名契約與運算子詞彙都不同。
完整 endpoint payload 見資料列參考與檢視參考;要實際比較 list/search,可在 API Playground 使用同一張表發送兩種請求。
型別化 projection 不擴大查詢權限
型別化查詢值 保留 scalar/運算子、decimal 精度與 ACL;目前 projection 不可用時退回 JSON,不是租戶查詢拒絕。不要宣稱有新的不限筆數或延遲保證。