- {conditions.length === 0 ?
No filters. Records aren’t filtered.
: null}
- {conditions.map((c, i) => {
- const field = fields.find((f) => f.fieldId === c.fieldId);
- const ops = field ? OPS_BY_KIND[kindOf(field)!]! : [];
- const opMeta = ops.find((o) => o.op === c.op);
- return (
-
- {i === 0 ? Where
- : {conjunction}}
-
-
- {opMeta && !opMeta.noValue ? update(i, { value: v })} /> : null}
-
-
- );
- })}
+ {items.length === 0 ?
No filters. Records aren’t filtered.
: null}
+ {items.map((item, i) => isFilterGroup(item) ? (
+
setItem(i, g)} onRemove={() => removeItem(i)}
+ />
+ ) : (
+ setItem(i, { ...item, ...patch })} onRemove={() => removeItem(i)}
+ />
+ ))}
- {conditions.length > 1 ? (
+
+ {items.length > 1 ? (
) : null}
@@ -175,6 +278,53 @@ function FilterEditor({
);
}
+/** A one-level AND/OR group: its own conjunction toggle + leaf condition rows. */
+function GroupRow({
+ group, fields, meta, lead, onChange, onRemove,
+}: {
+ group: FilterGroup;
+ fields: FieldMeta[];
+ meta: Meta;
+ lead: React.ReactNode;
+ onChange: (g: FilterGroup) => void;
+ onRemove: () => void;
+}) {
+ const conds = group.conditions ?? [];
+ const setCond = (i: number, patch: Partial
) =>
+ onChange({ ...group, conditions: conds.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
+ const removeCond = (i: number) => {
+ const next = conds.filter((_, j) => j !== i);
+ if (next.length === 0) { onRemove(); return; }
+ onChange({ ...group, conditions: next });
+ };
+ return (
+
+ {lead}
+
+
+ Group —
+
+
+
+ {conds.map((c, i) => (
+
When : {group.conjunction}}
+ onChange={(patch) => setCond(i, patch)} onRemove={() => removeCond(i)}
+ />
+ ))}
+
+
+
+ );
+}
+
function ValueInput({ field, value, onChange }: { field: FieldMeta; value: unknown; onChange: (v: unknown) => void }) {
const cls = "rounded border border-surface-border px-1 py-0.5 text-xs";
if (field.type === "select" && field.options?.choices) {
@@ -200,24 +350,46 @@ function ValueInput({ field, value, onChange }: { field: FieldMeta; value: unkno
);
}
-function SortEditor({ fields, sorts, onChange }: { fields: FieldMeta[]; sorts: NonNullable; onChange: (s: ViewConfig["sorts"]) => void }) {
+function SortEditor({ fields, meta, sorts, onChange }: { fields: FieldMeta[]; meta: Meta; sorts: NonNullable; onChange: (s: ViewConfig["sorts"]) => void }) {
+ // Default a link sort to the linked primary so it's immediately meaningful.
+ const defaultSort = (f: FieldMeta): NonNullable[number] => {
+ if (f.type === "link") {
+ const subs = linkedSubFields(f, meta);
+ const tid = linkedTableIdOf(f, meta);
+ const primaryId = meta.tables.find((t) => t.tableId === tid)?.primaryFieldId;
+ const linkedFieldId = subs.find((s) => s.fieldId === primaryId)?.fieldId ?? subs[0]?.fieldId;
+ return { fieldId: f.fieldId, linkedFieldId, direction: "asc" };
+ }
+ return { fieldId: f.fieldId, direction: "asc" };
+ };
+ const sel = "rounded border border-surface-border px-1 py-0.5 text-xs";
return (
{sorts.length === 0 ?
No sorts.
: null}
- {sorts.map((s, i) => (
-
-
-
-
-
- ))}
-
diff --git a/apps/web/lib/server/gridApi.ts b/apps/web/lib/server/gridApi.ts
index 0632578..ab8204a 100644
--- a/apps/web/lib/server/gridApi.ts
+++ b/apps/web/lib/server/gridApi.ts
@@ -34,7 +34,7 @@ export const getMeta = cache((): Promise => call("/v1/meta"));
export interface ListParams {
fields?: string[];
filter?: object;
- sort?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ sort?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
pageSize?: number;
offset?: string;
}
@@ -42,7 +42,15 @@ export interface ListParams {
export function listRecords(tableId: string, params: ListParams = {}): Promise {
const q = new URLSearchParams();
if (params.fields?.length) q.set("fields", params.fields.join(","));
- if (params.sort?.length) q.set("sort", params.sort.map((s) => `${s.fieldId}:${s.direction ?? "asc"}`).join(","));
+ // Sort token: "fieldId[>linkedFieldId]:dir" — `>` selects a linked sub-field.
+ if (params.sort?.length) {
+ q.set(
+ "sort",
+ params.sort
+ .map((s) => `${s.fieldId}${s.linkedFieldId ? `>${s.linkedFieldId}` : ""}:${s.direction ?? "asc"}`)
+ .join(","),
+ );
+ }
// Plain JSON; URLSearchParams percent-encodes it (handles UTF-8 + +/&/= safely).
if (params.filter) q.set("filter", JSON.stringify(params.filter));
if (params.pageSize) q.set("pageSize", String(params.pageSize));
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index 314396a..ba45c7c 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -44,19 +44,35 @@ export interface TableMeta {
export interface FilterCondition {
fieldId: string;
+ /** When `fieldId` is a link/lookup field, the sub-field of the linked table to
+ * compare on; defaults to the linked table's primary field. */
+ linkedFieldId?: string;
op: "is" | "isNot" | "isEmpty" | "isNotEmpty" | "contains" | "gt" | "lt" | "before" | "after" | "anyOf";
value?: unknown;
}
+/** A one-level-deep AND/OR group; contains only leaf conditions. */
+export interface FilterGroup {
+ conjunction: "and" | "or";
+ conditions: FilterCondition[];
+}
+
+export type FilterItem = FilterCondition | FilterGroup;
+
+/** True when a filter item is a group rather than a leaf condition. */
+export function isFilterGroup(item: FilterItem): item is FilterGroup {
+ return Array.isArray((item as FilterGroup).conditions);
+}
+
export interface ViewConfig {
fields?: Array<{ fieldId: string; width?: number }>;
filters?: {
conjunction: "and" | "or";
- conditions: FilterCondition[];
+ conditions: FilterItem[];
};
- sorts?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
groupBy?: string;
- kanban?: { stackFieldId: string };
+ kanban?: { stackFieldId: string; maxPreviewFields?: number };
calendar?: { dateFieldId: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };
diff --git a/apps/web/public/grid.png b/apps/web/public/grid.png
new file mode 100644
index 0000000..157728b
Binary files /dev/null and b/apps/web/public/grid.png differ
diff --git a/packages/api/src/aggregate.ts b/packages/api/src/aggregate.ts
index 8ac3a34..a49c4da 100644
--- a/packages/api/src/aggregate.ts
+++ b/packages/api/src/aggregate.ts
@@ -62,7 +62,7 @@ export function buildAggregate(spec: AggregateSpec, reg: Registry, tableId: stri
}
}
- const where = buildWhere(spec.filter, reg.fieldById, table.slug);
+ const where = buildWhere(spec.filter, reg, table.slug);
const select = groupExpr ? `${groupExpr} AS g, ${valueExpr} AS v` : `${valueExpr} AS v`;
const grouping = groupExpr ? ` GROUP BY ${groupExpr} ORDER BY ${groupExpr}` : "";
const sql = `SELECT ${select} FROM ${table.slug} ${where.sql}${grouping}`.replace(/\s+/g, " ").trim();
diff --git a/packages/api/src/dashboards.ts b/packages/api/src/dashboards.ts
index d3dde8b..f30b217 100644
--- a/packages/api/src/dashboards.ts
+++ b/packages/api/src/dashboards.ts
@@ -1,5 +1,6 @@
import { HttpError } from "./repo.js";
-import type { AggFn, DashboardConfig, DashboardMeta, Registry, Widget } from "./types.js";
+import { isFilterGroup } from "./types.js";
+import type { AggFn, DashboardConfig, DashboardMeta, FilterCondition, Registry, Widget } from "./types.js";
const WIDGET_TYPES = new Set(["kpi", "line", "bar", "table"]);
const AGGS = new Set(["count", "sum", "avg", "min", "max"]);
@@ -56,7 +57,13 @@ function validateConfig(config: DashboardConfig | undefined, reg: Registry): Das
if (!inTable(w.groupByFieldId)) throw new HttpError(400, "widget groupBy field is not in its table");
if (w.agg && !AGGS.has(w.agg)) throw new HttpError(400, `invalid agg: ${w.agg}`);
for (const fid of w.fieldIds ?? []) if (!inTable(fid)) throw new HttpError(400, "widget field is not in its table");
- for (const c of w.filter?.conditions ?? []) if (!inTable(c.fieldId)) throw new HttpError(400, "widget filter field is not in its table");
+ // Flatten one level of filter groups to validate every leaf condition's field.
+ const leaves: FilterCondition[] = [];
+ for (const item of w.filter?.conditions ?? []) {
+ if (isFilterGroup(item)) leaves.push(...item.conditions);
+ else leaves.push(item);
+ }
+ for (const c of leaves) if (!inTable(c.fieldId)) throw new HttpError(400, "widget filter field is not in its table");
// Fail fast on a widget that can't run: aggregates beyond count need a metric;
// charts need an x-axis.
if (w.type !== "table" && (w.agg ?? "count") !== "count" && !w.metricFieldId) {
diff --git a/packages/api/src/filter.test.ts b/packages/api/src/filter.test.ts
index 60db2f1..19c1007 100644
--- a/packages/api/src/filter.test.ts
+++ b/packages/api/src/filter.test.ts
@@ -1,26 +1,47 @@
import { describe, expect, it } from "vitest";
-import { buildWhere } from "./filter.js";
-import type { FieldMeta } from "./types.js";
+import { buildOrderBy, buildWhere } from "./filter.js";
+import type { FieldMeta, Registry, TableMeta } from "./types.js";
function field(partial: Partial & { fieldId: string; type: FieldMeta["type"] }): FieldMeta {
return {
- tableId: "t", name: partial.fieldId, columnName: partial.columnName ?? null,
+ tableId: partial.tableId ?? "t", name: partial.fieldId, columnName: partial.columnName ?? null,
position: 0, options: partial.options ?? null, isComputed: false, isPrimary: false,
...partial,
};
}
-const fields = new Map([
- ["fNum", field({ fieldId: "fNum", type: "number", columnName: "num" })],
- ["fTxt", field({ fieldId: "fTxt", type: "text", columnName: "txt" })],
- ["fChk", field({ fieldId: "fChk", type: "checkbox", columnName: "chk" })],
- ["fDt", field({ fieldId: "fDt", type: "datetime", columnName: "dt" })],
- ["fLink", field({ fieldId: "fLink", type: "link", options: { join: "lj", self: "a_id", other: "b_id", linkedTableId: "t2" } })],
- ["fFormula", field({ fieldId: "fFormula", type: "formula", columnName: null })],
-]);
+const allFields: FieldMeta[] = [
+ field({ fieldId: "fNum", type: "number", columnName: "num" }),
+ field({ fieldId: "fTxt", type: "text", columnName: "txt" }),
+ field({ fieldId: "fChk", type: "checkbox", columnName: "chk" }),
+ field({ fieldId: "fDt", type: "datetime", columnName: "dt" }),
+ field({ fieldId: "fLink", type: "link", options: { join: "lj", self: "a_id", other: "b_id", linkedTableId: "t2" } }),
+ field({ fieldId: "fFormula", type: "formula", columnName: null }),
+ // Linked table (t2 = companies) fields
+ field({ fieldId: "fCoName", type: "text", tableId: "t2", columnName: "co_name", isPrimary: true }),
+ field({ fieldId: "fCoTier", type: "text", tableId: "t2", columnName: "tier" }),
+ // A lookup that pulls the linked company's name via fLink.
+ field({ fieldId: "fLookup", type: "lookup", options: { lookup: { via: "fLink", target: "fCoName" } } }),
+];
-const where = (conds: Array<{ fieldId: string; op: string; value?: unknown }>) =>
- buildWhere({ conjunction: "and", conditions: conds as never }, fields, "tbl");
+const tables: TableMeta[] = [
+ { tableId: "t", workspaceId: null, name: "Roles", slug: "tbl", primaryFieldId: "fTxt", position: 0, sourceKind: "table", sourceRef: null },
+ { tableId: "t2", workspaceId: null, name: "Companies", slug: "companies", primaryFieldId: "fCoName", position: 1, sourceKind: "table", sourceRef: null },
+];
+
+const reg: Registry = {
+ tables,
+ fieldsByTable: new Map([
+ ["t", allFields.filter((f) => f.tableId === "t")],
+ ["t2", allFields.filter((f) => f.tableId === "t2")],
+ ]),
+ fieldById: new Map(allFields.map((f) => [f.fieldId, f])),
+ views: [],
+ dashboards: [],
+};
+
+const where = (conds: unknown[]) =>
+ buildWhere({ conjunction: "and", conditions: conds as never }, reg, "tbl");
describe("buildWhere", () => {
it("skips a value-requiring op with no value (half-built filter row)", () => {
@@ -66,4 +87,98 @@ describe("buildWhere", () => {
expect(r.sql).toBe(`WHERE "txt" = ? AND "num" > ?`);
expect(r.binds).toEqual(["x", 5]);
});
+
+ // ---- Feature C: nested AND/OR groups -------------------------------------
+
+ it("nested group: A AND (B OR C) wraps the group clauses in parens", () => {
+ const r = where([
+ { fieldId: "fTxt", op: "is", value: "x" },
+ {
+ conjunction: "or",
+ conditions: [
+ { fieldId: "fNum", op: "gt", value: 5 },
+ { fieldId: "fNum", op: "lt", value: 0 },
+ ],
+ },
+ ]);
+ expect(r.sql).toBe(`WHERE "txt" = ? AND ("num" > ? OR "num" < ?)`);
+ expect(r.binds).toEqual(["x", 5, 0]);
+ });
+
+ it("top-level OR over a group and a leaf", () => {
+ const r = buildWhere(
+ {
+ conjunction: "or",
+ conditions: [
+ { conjunction: "and", conditions: [{ fieldId: "fTxt", op: "is", value: "a" }, { fieldId: "fNum", op: "gt", value: 1 }] },
+ { fieldId: "fChk", op: "is", value: true },
+ ],
+ } as never,
+ reg,
+ "tbl",
+ );
+ expect(r.sql).toBe(`WHERE ("txt" = ? AND "num" > ?) OR "chk" = 1`);
+ expect(r.binds).toEqual(["a", 1]);
+ });
+
+ // ---- Feature A: linked-field WHERE (EXISTS over the link join) ------------
+
+ it("linked sub-field: link + linkedFieldId → correlated EXISTS joining the linked table", () => {
+ const r = where([{ fieldId: "fLink", linkedFieldId: "fCoName", op: "is", value: "Acme" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."co_name" = ?)`,
+ );
+ expect(r.binds).toEqual(["Acme"]);
+ });
+
+ it("linked sub-field contains uses LIKE inside the EXISTS subquery", () => {
+ const r = where([{ fieldId: "fLink", linkedFieldId: "fCoTier", op: "contains", value: "S" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."tier" LIKE ?)`,
+ );
+ expect(r.binds).toEqual(["%S%"]);
+ });
+
+ it("lookup field filters through its via-link to the target column (no explicit linkedFieldId)", () => {
+ const r = where([{ fieldId: "fLookup", op: "is", value: "Acme" }]);
+ expect(r.sql).toBe(
+ `WHERE EXISTS (SELECT 1 FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id AND lt."co_name" = ?)`,
+ );
+ expect(r.binds).toEqual(["Acme"]);
+ });
+});
+
+describe("buildOrderBy", () => {
+ it("stored columns sort directly", () => {
+ expect(buildOrderBy([{ fieldId: "fTxt", direction: "desc" }], reg, "tbl")).toBe(`ORDER BY "txt" DESC`);
+ });
+
+ it("skips formula/unsortable fields with no column", () => {
+ expect(buildOrderBy([{ fieldId: "fFormula", direction: "asc" }], reg, "tbl")).toBe("");
+ });
+
+ it("linked sub-field sorts by a correlated scalar subquery over the linked table", () => {
+ const sql = buildOrderBy([{ fieldId: "fLink", linkedFieldId: "fCoName", direction: "asc" }], reg, "tbl");
+ expect(sql).toBe(
+ `ORDER BY (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) ASC`,
+ );
+ });
+
+ it("lookup field sorts via its via-link target column", () => {
+ const sql = buildOrderBy([{ fieldId: "fLookup", direction: "desc" }], reg, "tbl");
+ expect(sql).toBe(
+ `ORDER BY (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) DESC`,
+ );
+ });
+
+ it("mixes a stored sort and a linked sort", () => {
+ const sql = buildOrderBy(
+ [{ fieldId: "fNum", direction: "desc" }, { fieldId: "fLink", linkedFieldId: "fCoName", direction: "asc" }],
+ reg,
+ "tbl",
+ );
+ expect(sql).toBe(
+ `ORDER BY "num" DESC, (SELECT lt."co_name" FROM lj j JOIN companies lt ON lt.id = j.b_id WHERE j.a_id = tbl.id LIMIT 1) ASC`,
+ );
+ });
});
diff --git a/packages/api/src/filter.ts b/packages/api/src/filter.ts
index 2d3b1ed..4fd8c86 100644
--- a/packages/api/src/filter.ts
+++ b/packages/api/src/filter.ts
@@ -1,10 +1,102 @@
-import type { FieldMeta, FilterCondition, FilterSpec, ViewConfig } from "./types.js";
+import type { FieldMeta, FilterCondition, FilterSpec, Registry, ViewConfig } from "./types.js";
+import { isFilterGroup } from "./types.js";
export interface SqlFragment {
sql: string;
binds: unknown[];
}
+/** Resolve the {join,self,other,linkedTableId} + linked-table slug + target
+ * column for traversing a link/lookup field to a sub-field of the linked
+ * table. Returns null when the shape can't be resolved (caller skips). */
+function resolveLinkTarget(
+ field: FieldMeta,
+ linkedFieldId: string | undefined,
+ reg: Registry,
+): { join: string; self: string; other: string; linkedSlug: string; targetCol: string } | null {
+ // A lookup field traverses its own `via` link; a link field traverses itself.
+ let linkField = field;
+ if (field.type === "lookup") {
+ const via = field.options?.lookup?.via;
+ const vf = via ? reg.fieldById.get(via) : undefined;
+ if (!vf) return null;
+ linkField = vf;
+ }
+ const { join, self, other, linkedTableId } = (linkField.options ?? {}) as {
+ join?: string; self?: string; other?: string; linkedTableId?: string;
+ };
+ if (!join || !self || !other || !linkedTableId) return null;
+ const linkedTable = reg.tables.find((t) => t.tableId === linkedTableId);
+ if (!linkedTable) return null;
+
+ // Default the sub-field to the linked table's primary; a lookup pins `target`.
+ let targetFieldId = linkedFieldId;
+ if (field.type === "lookup" && !targetFieldId) targetFieldId = field.options?.lookup?.target;
+ if (!targetFieldId) targetFieldId = linkedTable.primaryFieldId;
+ const targetField = reg.fieldById.get(targetFieldId);
+ if (!targetField?.columnName) return null;
+
+ return { join, self, other, linkedSlug: linkedTable.slug, targetCol: targetField.columnName };
+}
+
+/** Comparison clause + binds for ` ?` over an already-resolved column,
+ * shared by stored-column and linked-sub-field paths. `dateish` truncates date
+ * ops to the day. Returns null when the op needs a value it doesn't have. */
+function compare(col: string, type: FieldMeta["type"], op: FilterCondition["op"], value: unknown): SqlFragment | null {
+ const dateish = type === "date" || type === "datetime";
+ const val = resolveValue(value);
+ const valueOp = op !== "isEmpty" && op !== "isNotEmpty";
+ if (valueOp && type !== "checkbox" && (val === undefined || val === null)) return null;
+ const cval = type === "checkbox" ? (val ? 1 : 0) : val;
+
+ switch (op) {
+ case "is":
+ if (type === "checkbox") return { sql: cval ? `${col} = 1` : `(${col} IS NULL OR ${col} = 0)`, binds: [] };
+ return { sql: `${col} = ?`, binds: [cval] };
+ case "isNot":
+ if (type === "checkbox") return { sql: cval ? `(${col} IS NULL OR ${col} = 0)` : `${col} = 1`, binds: [] };
+ return { sql: `(${col} IS NULL OR ${col} != ?)`, binds: [cval] };
+ case "isEmpty": return { sql: emptyClause(col, type, false), binds: [] };
+ case "isNotEmpty": return { sql: emptyClause(col, type, true), binds: [] };
+ case "contains": return { sql: `${col} LIKE ?`, binds: [`%${String(cval)}%`] };
+ case "gt": return { sql: `${col} > ?`, binds: [cval] };
+ case "lt": return { sql: `${col} < ?`, binds: [cval] };
+ case "before": return { sql: dateish ? `date(${col}) < date(?)` : `${col} < ?`, binds: [cval] };
+ case "after": return { sql: dateish ? `date(${col}) > date(?)` : `${col} > ?`, binds: [cval] };
+ case "anyOf": {
+ const vals = Array.isArray(cval) ? cval : [cval];
+ if (vals.length === 0) return { sql: "0 = 1", binds: [] };
+ return { sql: `${col} IN (${vals.map(() => "?").join(", ")})`, binds: vals };
+ }
+ default: return null;
+ }
+}
+
+/** Filter on a sub-field of a linked record via a correlated EXISTS that joins
+ * the link's join table to the linked table and compares the target column.
+ * Emptiness is delegated to the link's own EXISTS shape. */
+function linkedFieldClause(
+ field: FieldMeta,
+ cond: FilterCondition,
+ tableSlug: string,
+ reg: Registry,
+): SqlFragment | null {
+ const t = resolveLinkTarget(field, cond.linkedFieldId, reg);
+ if (!t) return null;
+ const linkExists = `SELECT 1 FROM ${t.join} WHERE ${t.join}.${t.self} = ${tableSlug}.id`;
+ if (cond.op === "isEmpty") return { sql: `NOT EXISTS (${linkExists})`, binds: [] };
+ if (cond.op === "isNotEmpty") return { sql: `EXISTS (${linkExists})`, binds: [] };
+
+ // lt. is a bare column inside the subquery, not table-qualified at
+ // the outer level, so compare() gets `lt."col"`.
+ const cmp = compare(`lt."${t.targetCol}"`, reg.fieldById.get(cond.linkedFieldId ?? "")?.type ?? "text", cond.op, cond.value);
+ if (!cmp) return null;
+ const corr =
+ `EXISTS (SELECT 1 FROM ${t.join} j JOIN ${t.linkedSlug} lt ON lt.id = j.${t.other} ` +
+ `WHERE j.${t.self} = ${tableSlug}.id AND ${cmp.sql})`;
+ return { sql: corr, binds: cmp.binds };
+}
+
/** Resolve a relative date value ({relative:'daysAgo', n}) to an ISO date. */
function resolveValue(value: unknown): unknown {
if (value && typeof value === "object" && "relative" in (value as Record)) {
@@ -51,15 +143,41 @@ function linkClause(field: FieldMeta, cond: FilterCondition, tableSlug: string):
}
}
+/** Build the SQL fragment for a single leaf condition (stored column, link
+ * membership, or linked sub-field). Returns null when the condition should be
+ * skipped (unknown field, half-built row, formula). */
+function leafClause(
+ cond: FilterCondition,
+ reg: Registry,
+ tableSlug: string,
+): SqlFragment | null {
+ const field = reg.fieldById.get(cond.fieldId);
+ if (!field) return null;
+
+ // Link/lookup + a named sub-field → correlated EXISTS over the linked table.
+ // A lookup always traverses to its target even without an explicit linkedFieldId.
+ if ((field.type === "link" && cond.linkedFieldId) || field.type === "lookup") {
+ return linkedFieldClause(field, cond, tableSlug, reg);
+ }
+ // Link field with no sub-field → membership / emptiness over the join table.
+ if (field.type === "link" && field.options?.join) {
+ return linkClause(field, cond, tableSlug);
+ }
+ if (!field.columnName) return null; // formula → skip
+ return compare(`"${field.columnName}"`, field.type, cond.op, cond.value);
+}
+
/**
* Build a parameterized WHERE clause from a structured filter spec. Stored
- * columns and link fields are filterable; formula/lookup fields are skipped.
- * Returns an empty fragment when there's nothing to filter. Never interpolates
- * values — all go through bind params.
+ * columns, link fields, and (new) linked sub-fields / lookups are filterable;
+ * formula fields are skipped. Top-level items may be leaf conditions or
+ * one-level AND/OR groups (Airtable-style); a group's leaf clauses are
+ * parenthesized and joined by the group's own conjunction. Returns an empty
+ * fragment when there's nothing to filter. Never interpolates values.
*/
export function buildWhere(
filters: FilterSpec | undefined,
- fieldById: Map,
+ reg: Registry,
tableSlug: string,
): SqlFragment {
if (!filters || filters.conditions.length === 0) return { sql: "", binds: [] };
@@ -67,66 +185,22 @@ export function buildWhere(
const clauses: string[] = [];
const binds: unknown[] = [];
- for (const cond of filters.conditions) {
- const field = fieldById.get(cond.fieldId);
- if (!field) continue;
- if (field.type === "link" && field.options?.join) {
- const frag = linkClause(field, cond, tableSlug);
- if (frag) { clauses.push(frag.sql); binds.push(...frag.binds); }
- continue;
- }
- if (!field.columnName) continue; // formula / lookup → skip
- const col = `"${field.columnName}"`;
- let val = resolveValue(cond.value);
- // Skip value-requiring ops with no value yet (e.g. a half-built filter row in
- // the UI) — otherwise we'd bind undefined and error. Checkbox coerces nullish
- // to "unchecked", a valid filter, so it's exempt.
- const valueOp = cond.op !== "isEmpty" && cond.op !== "isNotEmpty";
- if (valueOp && field.type !== "checkbox" && (val === undefined || val === null)) continue;
- // Checkboxes are stored as 1 / NULL (false is never stored), so normalize.
- if (field.type === "checkbox") val = val ? 1 : 0;
- // before/after are date semantics; truncate both sides to the day so a
- // datetime column ('2026-06-09T09:00') compares correctly against a date.
- const dateish = field.type === "date" || field.type === "datetime";
-
- switch (cond.op) {
- case "is":
- if (field.type === "checkbox") {
- clauses.push(val ? `${col} = 1` : `(${col} IS NULL OR ${col} = 0)`);
- } else {
- clauses.push(`${col} = ?`);
- binds.push(val);
- }
- break;
- case "isNot":
- if (field.type === "checkbox") {
- clauses.push(val ? `(${col} IS NULL OR ${col} = 0)` : `${col} = 1`);
- } else {
- clauses.push(`(${col} IS NULL OR ${col} != ?)`);
- binds.push(val);
- }
- break;
- case "isEmpty": clauses.push(emptyClause(col, field.type, false)); break;
- case "isNotEmpty": clauses.push(emptyClause(col, field.type, true)); break;
- case "contains": clauses.push(`${col} LIKE ?`); binds.push(`%${String(val)}%`); break;
- case "gt": clauses.push(`${col} > ?`); binds.push(val); break;
- case "lt": clauses.push(`${col} < ?`); binds.push(val); break;
- case "before":
- clauses.push(dateish ? `date(${col}) < date(?)` : `${col} < ?`);
- binds.push(val);
- break;
- case "after":
- clauses.push(dateish ? `date(${col}) > date(?)` : `${col} > ?`);
- binds.push(val);
- break;
- case "anyOf": {
- const vals = Array.isArray(val) ? val : [val];
- if (vals.length === 0) { clauses.push("0 = 1"); break; }
- clauses.push(`${col} IN (${vals.map(() => "?").join(", ")})`);
- binds.push(...vals);
- break;
+ for (const item of filters.conditions) {
+ if (isFilterGroup(item)) {
+ const inner: string[] = [];
+ const innerBinds: unknown[] = [];
+ for (const cond of item.conditions) {
+ const frag = leafClause(cond, reg, tableSlug);
+ if (frag) { inner.push(frag.sql); innerBinds.push(...frag.binds); }
}
+ if (inner.length === 0) continue;
+ const joiner = item.conjunction === "or" ? " OR " : " AND ";
+ clauses.push(`(${inner.join(joiner)})`);
+ binds.push(...innerBinds);
+ continue;
}
+ const frag = leafClause(item, reg, tableSlug);
+ if (frag) { clauses.push(frag.sql); binds.push(...frag.binds); }
}
if (clauses.length === 0) return { sql: "", binds: [] };
@@ -134,17 +208,30 @@ export function buildWhere(
return { sql: `WHERE ${clauses.join(joiner)}`, binds };
}
-/** Build an ORDER BY clause from view sorts. Only stored columns are sortable. */
+/** Build an ORDER BY clause from view sorts. Stored columns sort directly;
+ * link/lookup fields sort by a correlated scalar subquery over the linked
+ * table's sub-field (default = linked primary). */
export function buildOrderBy(
sorts: ViewConfig["sorts"],
- fieldById: Map,
+ reg: Registry,
+ tableSlug: string,
): string {
if (!sorts || sorts.length === 0) return "";
const parts: string[] = [];
for (const s of sorts) {
- const field = fieldById.get(s.fieldId);
- if (!field || !field.columnName) continue;
+ const field = reg.fieldById.get(s.fieldId);
+ if (!field) continue;
const dir = s.direction === "desc" ? "DESC" : "ASC";
+ if ((field.type === "link" && s.linkedFieldId) || field.type === "lookup") {
+ const t = resolveLinkTarget(field, s.linkedFieldId, reg);
+ if (!t) continue;
+ parts.push(
+ `(SELECT lt."${t.targetCol}" FROM ${t.join} j JOIN ${t.linkedSlug} lt ON lt.id = j.${t.other} ` +
+ `WHERE j.${t.self} = ${tableSlug}.id LIMIT 1) ${dir}`,
+ );
+ continue;
+ }
+ if (!field.columnName) continue;
parts.push(`"${field.columnName}" ${dir}`);
}
return parts.length ? `ORDER BY ${parts.join(", ")}` : "";
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 74d8698..b3c4a06 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -30,6 +30,7 @@ import {
type WriteInput,
} from "./repo.js";
import { createView, deleteView, updateView } from "./views.js";
+import { isFilterGroup } from "./types.js";
import type { AggregateSpec, DashboardConfig, FilterSpec, Registry, ViewConfig } from "./types.js";
export interface Env {
@@ -49,6 +50,7 @@ function authorized(request: Request, env: Env): boolean {
const MAX_FILTER_CONDITIONS = 50;
const MAX_ANYOF_VALUES = 500;
+const MAX_FILTER_GROUPS = 10;
const MAX_WRITE_BATCH = 50;
const json = (data: unknown, status = 200) =>
@@ -106,16 +108,36 @@ function parseAggregateSpec(url: URL): AggregateSpec {
return spec;
}
-/** Bound a filter so one request can't build a pathologically large query. */
+/** Bound a filter spec so one request can't build a pathologically large query.
+ * Caps total leaf conditions, group count, anyOf value lists, and nesting depth
+ * (groups are one level deep — a group may not itself contain a group). */
function assertFilterBounds(parsed: FilterSpec): FilterSpec {
- const conditions = parsed.conditions ?? [];
- if (conditions.length > MAX_FILTER_CONDITIONS) {
- throw new HttpError(400, `too many filter conditions (max ${MAX_FILTER_CONDITIONS})`);
- }
- for (const c of conditions) {
+ const top = parsed.conditions ?? [];
+ let leafCount = 0;
+ let groupCount = 0;
+ const checkLeaf = (c: { value?: unknown }) => {
if (Array.isArray(c.value) && c.value.length > MAX_ANYOF_VALUES) {
throw new HttpError(400, `too many values in a condition (max ${MAX_ANYOF_VALUES})`);
}
+ };
+ for (const item of top) {
+ if (isFilterGroup(item)) {
+ groupCount++;
+ for (const inner of item.conditions ?? []) {
+ if (isFilterGroup(inner as never)) throw new HttpError(400, "filter groups may not be nested");
+ leafCount++;
+ checkLeaf(inner);
+ }
+ } else {
+ leafCount++;
+ checkLeaf(item);
+ }
+ }
+ if (leafCount > MAX_FILTER_CONDITIONS) {
+ throw new HttpError(400, `too many filter conditions (max ${MAX_FILTER_CONDITIONS})`);
+ }
+ if (groupCount > MAX_FILTER_GROUPS) {
+ throw new HttpError(400, `too many filter groups (max ${MAX_FILTER_GROUPS})`);
}
return parsed;
}
@@ -129,8 +151,14 @@ function parseListOpts(url: URL): ListOpts {
const sort = url.searchParams.get("sort");
if (sort) {
opts.sorts = sort.split(",").filter(Boolean).map((s) => {
- const [fieldId, dir] = s.split(":");
- return { fieldId: fieldId!, direction: dir === "desc" ? "desc" : "asc" };
+ // Token shape: "fieldId[>linkedFieldId]:dir" — `>` selects a linked sub-field.
+ const [spec, dir] = s.split(":");
+ const [fieldId, linkedFieldId] = spec!.split(">");
+ return {
+ fieldId: fieldId!,
+ ...(linkedFieldId ? { linkedFieldId } : {}),
+ direction: dir === "desc" ? "desc" as const : "asc" as const,
+ };
});
}
diff --git a/packages/api/src/repo.ts b/packages/api/src/repo.ts
index 31be58a..5751160 100644
--- a/packages/api/src/repo.ts
+++ b/packages/api/src/repo.ts
@@ -155,8 +155,8 @@ export async function listRecords(
const fields = reg.fieldsByTable.get(tableId) ?? [];
const wanted = opts.fields && opts.fields.length ? new Set(opts.fields) : undefined;
- const where = buildWhere(opts.filters, reg.fieldById, table.slug);
- const orderBy = buildOrderBy(opts.sorts, reg.fieldById);
+ const where = buildWhere(opts.filters, reg, table.slug);
+ const orderBy = buildOrderBy(opts.sorts, reg, table.slug);
const offset = Math.max(0, Number.parseInt(opts.offset ?? "0", 10) || 0);
// Clamp to [0, MAX_PAGE]: a negative LIMIT means "no limit" in SQLite, so an
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
index fd66ebc..6660563 100644
--- a/packages/api/src/types.ts
+++ b/packages/api/src/types.ts
@@ -79,21 +79,39 @@ export interface ViewMeta {
export interface FilterCondition {
fieldId: string;
+ /** When `fieldId` is a link (or lookup) field, names the sub-field in the
+ * linked table to compare on; defaults to the linked table's primary field. */
+ linkedFieldId?: string;
op: "is" | "isNot" | "isEmpty" | "isNotEmpty" | "contains" | "gt" | "lt" | "before" | "after" | "anyOf";
value?: unknown;
}
-export interface FilterSpec {
+/** A one-level-deep AND/OR group; contains only leaf conditions. */
+export interface FilterGroup {
conjunction: "and" | "or";
conditions: FilterCondition[];
}
+/** True when an item in a FilterSpec is a group (has nested conditions) rather
+ * than a leaf condition (which carries an `op`). */
+export function isFilterGroup(item: FilterCondition | FilterGroup): item is FilterGroup {
+ return Array.isArray((item as FilterGroup).conditions);
+}
+
+export interface FilterSpec {
+ conjunction: "and" | "or";
+ /** Top-level items: leaf conditions and/or one-level groups. Old flat specs
+ * (conditions only) remain valid. */
+ conditions: Array;
+}
+
export interface ViewConfig {
fields?: Array<{ fieldId: string; width?: number }>;
filters?: FilterSpec;
- sorts?: Array<{ fieldId: string; direction?: "asc" | "desc" }>;
+ /** `linkedFieldId` sorts by a sub-field of the linked table (see FilterCondition). */
+ sorts?: Array<{ fieldId: string; linkedFieldId?: string; direction?: "asc" | "desc" }>;
groupBy?: string;
- kanban?: { stackFieldId: string };
+ kanban?: { stackFieldId: string; maxPreviewFields?: number };
calendar?: { dateFieldId: string };
form?: { title?: string; fieldIds: string[]; redirectMessage?: string };
dashboard?: { dateFieldId: string; metricFieldIds: string[] };