Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
753 changes: 753 additions & 0 deletions crates/spock-runtime/src/filter.rs

Large diffs are not rendered by default.

468 changes: 426 additions & 42 deletions crates/spock-runtime/src/graphql.rs

Large diffs are not rendered by default.

59 changes: 36 additions & 23 deletions crates/spock-runtime/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,30 @@ use serde_json::{json, Map, Value as JsonValue};
use spock_lang::ir::{FnArity, Table, Type};

use crate::error::ApiError;
use crate::filter;
use crate::graphql::{self, SchemaBuildError};
use crate::App;
use crate::{func, write};

const DEFAULT_LIMIT: u32 = 50;
const MAX_LIMIT: u32 = 200;
/// The dev persona picker caps its projection so a large dev database cannot
/// blow up the dropdown (RFD 0015 §6.1).
const PERSONA_CAP: u32 = 100;

/// Startup failures: schema-derivation collisions (§8.2 naming laws) or a
/// table claiming a protocol-owned REST segment. Both abort load — never
/// Startup failures: schema-derivation collisions (§8.2 naming laws), a table
/// claiming a protocol-owned REST segment, or a column whose name collides
/// with a reserved PostgREST control key (RFD 0021 §6). All abort load — never
/// a request-time surprise.
#[derive(Debug, thiserror::Error)]
pub enum StartupError {
#[error(transparent)]
Schema(#[from] SchemaBuildError),
#[error("table `rpc` collides with the protocol-owned /rest/v1/rpc segment")]
ReservedRestSegment,
#[error(
"table `{table}` column `{column}` collides with a reserved filter control key \
(order, limit, offset, select, and, or, not)"
)]
ReservedFilterColumn { table: String, column: String },
}

pub fn router(app: Arc<App>) -> Result<Router, StartupError> {
Expand All @@ -49,6 +54,19 @@ pub fn router(app: Arc<App>) -> Result<Router, StartupError> {
if app.contract.table("rpc").is_some() {
return Err(StartupError::ReservedRestSegment);
}
// A column named like a PostgREST control key (`order`, `and`, …) would be
// unaddressable / ambiguous in a REST filter, so it fails at load, not per
// request — the exact set the parser treats specially (RFD 0021 §6).
for table in &app.contract.tables {
for field in &table.fields {
if filter::REST_RESERVED_KEYS.contains(&field.name.as_str()) {
return Err(StartupError::ReservedFilterColumn {
table: table.name.clone(),
column: field.name.clone(),
});
}
}
}
let schema = graphql::schema(app.clone())?;
let gql = Router::new()
.route("/graphql/v1", get(graphiql).post(graphql_post))
Expand Down Expand Up @@ -414,31 +432,26 @@ fn resolve_table<'a>(app: &'a App, name: &str) -> Result<&'a Table, ApiError> {
.ok_or_else(|| ApiError::not_found(format!("no table `{name}` in this contract")))
}

// GET /{table}?limit=N — list rows, key order, capped (§8)
// GET /{table}?col=op.val&order=…&limit=…&offset=… — filtered, ordered,
// paged reads (RFD 0021 §6). The PostgREST operator grammar parses into the
// one predicate IR and runs through the same composer as the GraphQL floor.
// Query params are an ordered `Vec` (not a map): repeated keys — multiple
// column filters, multiple `order` terms — are honored in order.
async fn list_rows(
State(app): State<Arc<App>>,
Path(table): Path<String>,
Query(params): Query<HashMap<String, String>>,
Query(params): Query<Vec<(String, String)>>,
) -> Result<Json<JsonValue>, ApiError> {
let table = resolve_table(&app, &table)?;
let q = filter::parse_rest(&app.contract, table, &params)?;

let limit = match params.get("limit") {
None => DEFAULT_LIMIT,
Some(raw) => raw
.parse::<u32>()
.map_err(|_| ApiError::bad_request("`limit` must be a non-negative integer"))?
.min(MAX_LIMIT),
};

let order = table
.key
.iter()
.map(|k| format!("\"{k}\" ASC"))
.collect::<Vec<_>>()
.join(", ");
let mut sql_params: Vec<SqlValue> = Vec::new();
let where_sql = filter::lower_where(&q.predicate, &mut sql_params);
filter::check_params(&sql_params)?;
let order_sql = filter::lower_order(&q.order, &table.key);
let sql = format!(
"SELECT * FROM \"{}\" ORDER BY {order} LIMIT {limit}",
table.name
"SELECT * FROM \"{}\" WHERE {where_sql} ORDER BY {order_sql} LIMIT {} OFFSET {}",
table.name, q.limit, q.offset
);

let db = app
Expand All @@ -449,7 +462,7 @@ async fn list_rows(
.prepare(&sql)
.map_err(|e| ApiError::internal(format!("sqlite: {e}")))?;
let mut rows = stmt
.query([])
.query(rusqlite::params_from_iter(sql_params.iter()))
.map_err(|e| ApiError::internal(format!("sqlite: {e}")))?;

let mut out = Vec::new();
Expand Down
1 change: 1 addition & 0 deletions crates/spock-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

pub mod engine;
pub mod error;
pub mod filter;
pub mod func;
pub mod graphql;
pub mod http;
Expand Down
42 changes: 42 additions & 0 deletions crates/spock-runtime/studio/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger

function PopoverContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"side" | "sideOffset" | "align" | "alignOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"relative isolate z-50 w-72 origin-(--transform-origin) rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}

export { Popover, PopoverTrigger, PopoverContent }
104 changes: 104 additions & 0 deletions crates/spock-runtime/studio/src/lib/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// The studio table view builds PostgREST-dialect query strings that the runtime
// REST frontend (crates/spock-runtime/src/filter.rs `parse_rest`) lowers into
// the one owned predicate IR — RFD 0021. This is the studio-side mirror of that
// grammar: the operator menu, and the `filter/sort/page` → query-string builder.
// Values are always carried as `URLSearchParams` values (percent-encoded), so a
// literal `%`/`(`/`,` round-trips intact; safety on the floor is structural
// (bound `?` params), never string-escaping here.

import type { TypeRef } from "@/types"

// The v0 operator set the floor supports (filter.rs §4). `_like` (case-
// sensitive) is deliberately absent — the SQLite floor refuses it; `ilike` is
// the case-insensitive form. `isnull`/`notnull` take no value.
export type Arity = "value" | "pattern" | "list" | "unary"

export interface OpDef {
key: string
label: string
// the PostgREST/SQL-ish symbol shown in the operator menu, Supabase-style
symbol: string
arity: Arity
}

export const FILTER_OPS: OpDef[] = [
{ key: "eq", label: "equals", symbol: "=", arity: "value" },
{ key: "neq", label: "not equal", symbol: "<>", arity: "value" },
{ key: "gt", label: "greater than", symbol: ">", arity: "value" },
{ key: "gte", label: "greater than or equal", symbol: ">=", arity: "value" },
{ key: "lt", label: "less than", symbol: "<", arity: "value" },
{ key: "lte", label: "less than or equal", symbol: "<=", arity: "value" },
{ key: "ilike", label: "like (case-insensitive)", symbol: "~~*", arity: "pattern" },
{ key: "in", label: "in list", symbol: "in", arity: "list" },
{ key: "isnull", label: "is null", symbol: "= ∅", arity: "unary" },
{ key: "notnull", label: "is not null", symbol: "≠ ∅", arity: "unary" },
]

const OP_BY_KEY = new Map(FILTER_OPS.map((o) => [o.key, o]))

export function opDef(key: string): OpDef {
return OP_BY_KEY.get(key) ?? FILTER_OPS[0]
}

export interface FilterRule {
id: number
column: string
op: string
value: string
}

export interface SortRule {
column: string
dir: "asc" | "desc"
}

// The REST value for one rule, e.g. `eq.7`, `ilike.%foo%`, `in.(a,b)`,
// `is.null`, `not.is.null`. Returns null for an incomplete rule (no column, or
// a value-taking op with an empty value) so half-typed filters simply don't
// apply — mirroring Supabase, which never sends an incomplete filter.
export function restValue(rule: FilterRule): string | null {
if (!rule.column) return null
const arity = opDef(rule.op).arity
if (rule.op === "isnull") return "is.null"
if (rule.op === "notnull") return "not.is.null"
const v = rule.value.trim()
if (v === "") return null
if (arity === "list") {
const inner = v.replace(/^\(/, "").replace(/\)$/, "")
return `in.(${inner})`
}
return `${rule.op}.${v}`
}

// Build the `/rest/v1/{table}?…` query string from the applied filters, sorts,
// and page window. Filters use repeated keys (`?a=eq.1&a=lt.9`) — honored in
// order by the ordered parse on the floor; order/limit/offset are singletons.
export function buildQuery(
filters: FilterRule[],
sorts: SortRule[],
limit: number,
offset: number,
): string {
const p = new URLSearchParams()
for (const f of filters) {
const rest = restValue(f)
if (rest) p.append(f.column, rest)
}
if (sorts.length) {
p.set("order", sorts.map((s) => `${s.column}.${s.dir}`).join(","))
}
p.set("limit", String(limit))
if (offset > 0) p.set("offset", String(offset))
return p.toString()
}

// A rule is "active" (contributes a predicate) iff it lowers to a REST value.
export function isActiveRule(rule: FilterRule): boolean {
return restValue(rule) !== null
}

// Closed-set columns get a value dropdown (Supabase renders enums as selects);
// everything else is a free-text value.
export function setValues(type: TypeRef | undefined): string[] | null {
return type?.kind === "set" ? (type.values ?? []) : null
}
Loading
Loading