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_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_emptylink 是否有連結、attachment 是否空,或 interval cell 是否空省略 value
overlapsinterval cell 與半開查詢區間重疊剛好兩個標準 YYYY-MM-DD HH:MM datetime,[start, end],且 start < end
contains_pointinterval cell 包含該時間點一個標準 YYYY-MM-DD HH:MM datetime
name_eq / name_containsuser / 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、rollup filter,以及 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 值做不分大小寫的部分比對,含 % 或 _ 的值會被當成原始 LIKE pattern;其他純量做精確比對。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;其他運算子是 400 link column filter supports only op 'in'/'eq'/'is_empty'/'is_not_empty' (got '<op>')。
    • Attachment 欄只接受 is_empty 與 is_not_empty;其他一律 400 attachment columns cannot be filtered。
    • Interval 欄只接受 overlaps、contains_point、is_empty 與 is_not_empty。其他一律 400 interval columns support only overlaps/contains_point/is_empty/is_not_empty (got '<op>'),而對純量或 json 欄使用 overlaps / contains_point 是 400 filter 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 欄位。)
  • 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。結果為精確小數的計算欄接受小數字串運算元;當查詢範圍內有一列無法精確求值時,整個請求會以 400 ct.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_ofeq、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_exists where 條件、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 } }
欄位型別限制
amountinteger1 到 730
unitstringdays 或 hours
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

互補的邊界只會讓 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、JSON null 或 [] 都算空);is_null / is_not_null:只檢查 cell 是否缺 key 或為 JSON null。與 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 與 decimal key 是數值排序而不是字典序(decimal 為精確排序),interval key 則依它的 start 界線排序。第 4 個 key 在資料列搜尋、匯出與已儲存檢視都是 422;公開讀取的 query 上限相同,自後端 5.22.0 起回 400 invalid 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 applyskip + limit,或 page_mode=cursor + cursor + limit(cursor 分頁)
records/search POSToffset + 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 SDKpage_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 列的一頁:400 expand_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;public GET /read/{token_id}/records 的 limit query 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 放在 bodyoffset(預設)或 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_cursornull下一頁的 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_pagesoperation: "count"
Agent custom_tables_find_records送入 cursor,回傳 has_more 與 next_cursor;沒有 total無
MCP records_querypage_mode: "cursor",之後送 cursor;cursor 模式的 total 是 null,預設的 offset 模式仍保留已棄用的精確 totalrecords_count 工具
Workspace ct.query、teamsync data ct query 與 teamsync_ct_querypage_mode: "cursor",之後送 cursor;預設仍是 offsetoperation: "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

  1. 在請求加上 page_mode=cursor(搜尋放在 body 裡),保留 limit,拿掉 skip 與 offset。
  2. 用一個 cursor 變數記住讀到哪裡:第一頁沒有,之後是剛收到那一頁的 next_cursor;has_more 為 false 就停。要讓使用者往回翻,請在畫面自己的狀態裡保留用過的 cursor。cursor 要送回發出它的同一條路由。
  3. 不要再讀 total 與 table.record_count,兩者都是 null。頁碼改成「載入更多」或上一頁/下一頁。要顯示數字的地方,每次搜尋呼叫一次 records/count。
  4. 篩選、搜尋文字、排序或檢視一有變動,就丟掉 cursor,請求第一頁。收到 400 ct.invalid_cursor 也一樣處理。
  5. 一次遷移一個列表:沒有帶 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 隱藏的目標共用 null group。multi_select、json、attachment、interval 或計算欄不論當 group_by key 或 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、JSON null 與 "" 都排除),"*" 計算列數,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 表示的精確字串;缺少時是 400 decimal 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,000400,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 的純數字字面值,維持原本的位元組;string cell 裡的相同文字會被標記。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,不是租戶查詢拒絕。不要宣稱有新的不限筆數或延遲保證。

Last updated on