mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: PostgREST-style document filtering and ordering
GET /collections/:id/documents accepts filter (field.op.value terms; eq neq gt gte lt lte like in is; dotted paths index into document data) and order (field.asc|desc). Whitelisted field/operator parsing with parameterized values; plain lists keep the sqlc path. CLI gains `documents list --collection --filter --order`; OpenAPI + generated client updated. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f9e7002048
commit
8892c6dc43
11 files changed
+489
-8
No files matched your search
@@ -17,6 +17,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||||
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||||
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||||
|
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||||
|
|
||||||
## [0.7.1] - 2026-09-23
|
## [0.7.1] - 2026-09-23
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -281,6 +281,20 @@ Arca-inspired secrets management, clean-room — every surface covered.
|
|||||||
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
|
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
|
||||||
so agents never hold plaintext.
|
so agents never hold plaintext.
|
||||||
|
|
||||||
|
## Phase 11 — Document queries (unreleased)
|
||||||
|
|
||||||
|
PostgREST-style filtering and ordering on collection documents.
|
||||||
|
|
||||||
|
- `GET /collections/:id/documents?filter=…&order=…` — `filter` is a
|
||||||
|
comma-separated `field.op.value` list (ops `eq neq gt gte lt lte like in
|
||||||
|
is`), `order` a `field.asc|field.desc` list. Dotted paths index into
|
||||||
|
document data (`meta.city.eq.Prague`); `id`/`created_at`/`updated_at` are
|
||||||
|
filterable columns.
|
||||||
|
- Whitelisted field/operator parsing, parameterized values, JSONB
|
||||||
|
extraction — no interpolated SQL. Plain lists keep the sqlc path.
|
||||||
|
- `primora documents list --collection <slug> --filter … --order …` in the
|
||||||
|
CLI; `filter`/`order` params in the generated client.
|
||||||
|
|
||||||
## Future phases — candidates
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||||
@@ -295,9 +309,6 @@ Ordered loosely by leverage. None committed; each gets scoped when picked.
|
|||||||
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
||||||
the existing bucket abstraction (deferred from Phase 6), plus
|
the existing bucket abstraction (deferred from Phase 6), plus
|
||||||
presigned-URL uploads for large files.
|
presigned-URL uploads for large files.
|
||||||
- **Phase 11 — Generated REST per collection** — PostgREST-style
|
|
||||||
auto-CRUD + filtering over Collections documents; a clean read layer
|
|
||||||
on data that already exists.
|
|
||||||
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
||||||
heartbeat silence) fanning out through the webhook dispatcher.
|
heartbeat silence) fanning out through the webhook dispatcher.
|
||||||
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
@@ -1057,12 +1058,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor, _ := middleware.ActorFromContext(c)
|
actor, _ := middleware.ActorFromContext(c)
|
||||||
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
var result []db.CoreDocument
|
||||||
if err != nil {
|
var total int64
|
||||||
h.handleError(c, err)
|
var err error
|
||||||
return
|
// PostgREST-style filter/order switches to the dynamic-query path; the
|
||||||
|
// plain list keeps using the sqlc queries it always has.
|
||||||
|
if filter := c.Query("filter"); filter != "" || c.Query("order") != "" {
|
||||||
|
result, total, err = h.Platform.SearchDocuments(c.Request.Context(), actor, collectionID, filter, c.Query("order"), limit, offset)
|
||||||
|
} else {
|
||||||
|
result, err = h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
||||||
|
if err == nil {
|
||||||
|
total, err = h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.handleError(c, err)
|
h.handleError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ func (r *CoreRepository) Queries() *db.Queries {
|
|||||||
return r.queries
|
return r.queries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool exposes the pgx pool for dynamic queries sqlc can't express
|
||||||
|
// (PostgREST-style document filtering builds its WHERE clause at runtime).
|
||||||
|
func (r *CoreRepository) Pool() *pgxpool.Pool {
|
||||||
|
return r.pool
|
||||||
|
}
|
||||||
|
|
||||||
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
||||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostgREST-lite filtering over document JSONB:
|
||||||
|
// ?filter=status.eq.active,age.gt.18,role.in.admin|editor
|
||||||
|
// ?order=name.asc,created_at.desc
|
||||||
|
// Values are always parameterized; only the whitelisted operators and
|
||||||
|
// field-path syntax below ever reach the SQL string.
|
||||||
|
|
||||||
|
var docFieldRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
// documentColumns are real table columns — everything else resolves into data.
|
||||||
|
var documentColumns = map[string]string{
|
||||||
|
"id": "id",
|
||||||
|
"created_at": "created_at",
|
||||||
|
"updated_at": "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// docFieldExpr maps a (possibly dotted) field path to a SQL text expression.
|
||||||
|
// data #>> '{a,b}' extracts nested values as text — the correct comparison
|
||||||
|
// domain for loosely-typed document fields.
|
||||||
|
func docFieldExpr(field string) (string, error) {
|
||||||
|
if col, ok := documentColumns[field]; ok {
|
||||||
|
return col, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(field, ".")
|
||||||
|
for _, p := range parts {
|
||||||
|
if !docFieldRe.MatchString(p) {
|
||||||
|
return "", fmt.Errorf("invalid field name %q — use [A-Za-z_][A-Za-z0-9_]* segments", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("data #>> '{%s}'", strings.Join(parts, ",")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericExpr wraps the field for ordered comparisons — only used when the
|
||||||
|
// filter value itself parses as a float, so the cast can never fail.
|
||||||
|
func numericExpr(expr string) string {
|
||||||
|
return fmt.Sprintf("(%s)::numeric", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDocumentFilters turns "field.op.value" terms into parameterized SQL.
|
||||||
|
// Returns the WHERE fragments and the argument list (already $N-numbered via
|
||||||
|
// startArg). A fragment is empty for `is` filters since they carry no value.
|
||||||
|
func buildDocumentFilters(filterParam string, startArg int) ([]string, []any, error) {
|
||||||
|
var conds []string
|
||||||
|
var args []any
|
||||||
|
argN := startArg
|
||||||
|
next := func(v any) string {
|
||||||
|
args = append(args, v)
|
||||||
|
argN++
|
||||||
|
return fmt.Sprintf("$%d", argN-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var docOps = []string{"eq", "neq", "gt", "gte", "lt", "lte", "like", "in", "is"}
|
||||||
|
|
||||||
|
for _, term := range strings.Split(filterParam, ",") {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Field paths are themselves dotted, so the operator is located by
|
||||||
|
// scanning for the first ".<known-op>." — the value may contain dots.
|
||||||
|
var field, op, value string
|
||||||
|
best := -1
|
||||||
|
for _, candidate := range docOps {
|
||||||
|
needle := "." + candidate + "."
|
||||||
|
if i := strings.Index(term, needle); i > 0 && (best < 0 || i < best) {
|
||||||
|
best = i
|
||||||
|
field, op, value = term[:i], candidate, term[i+len(needle):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field == "" {
|
||||||
|
return nil, nil, fmt.Errorf("invalid filter %q — expected field.op.value (ops: %s)", term, strings.Join(docOps, ", "))
|
||||||
|
}
|
||||||
|
expr, err := docFieldExpr(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
_, isFloat := strconv.ParseFloat(value, 64)
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "eq", "neq":
|
||||||
|
sym := "="
|
||||||
|
if op == "neq" {
|
||||||
|
sym = "<>"
|
||||||
|
}
|
||||||
|
conds = append(conds, fmt.Sprintf("%s %s %s", expr, sym, next(value)))
|
||||||
|
case "gt", "gte", "lt", "lte":
|
||||||
|
if isFloat != nil {
|
||||||
|
return nil, nil, fmt.Errorf("filter %q: %s requires a numeric value", term, op)
|
||||||
|
}
|
||||||
|
sym := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
|
||||||
|
f, _ := strconv.ParseFloat(value, 64)
|
||||||
|
conds = append(conds, fmt.Sprintf("%s %s %s", numericExpr(expr), sym, next(f)))
|
||||||
|
case "like":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s ILIKE %s", expr, next(value)))
|
||||||
|
case "in":
|
||||||
|
items := strings.Split(value, "|")
|
||||||
|
for i, item := range items {
|
||||||
|
items[i] = strings.TrimSpace(item)
|
||||||
|
}
|
||||||
|
conds = append(conds, fmt.Sprintf("%s = ANY(%s)", expr, next(items)))
|
||||||
|
case "is":
|
||||||
|
switch value {
|
||||||
|
case "null":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s IS NULL", expr))
|
||||||
|
case "true", "false":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s = %s", expr, next(value)))
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("filter %q: is accepts null|true|false", term)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unknown operator %q in filter %q — supported: eq, neq, gt, gte, lt, lte, like, in, is", op, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conds, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDocumentOrder turns "name.asc,created_at.desc" into an ORDER BY list.
|
||||||
|
func buildDocumentOrder(orderParam string) (string, error) {
|
||||||
|
if orderParam == "" {
|
||||||
|
return "created_at DESC", nil
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, term := range strings.Split(orderParam, ",") {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Direction is a suffix, not a split — field paths are dotted too.
|
||||||
|
field, dir := term, "asc"
|
||||||
|
if strings.HasSuffix(term, ".desc") {
|
||||||
|
field, dir = strings.TrimSuffix(term, ".desc"), "desc"
|
||||||
|
} else if strings.HasSuffix(term, ".asc") {
|
||||||
|
field = strings.TrimSuffix(term, ".asc")
|
||||||
|
}
|
||||||
|
expr, err := docFieldExpr(field)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts = append(parts, expr+" "+strings.ToUpper(dir))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "created_at DESC", nil
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDocuments lists documents with PostgREST-style filter/order params.
|
||||||
|
// Returns the page plus the un-paginated match count for "total".
|
||||||
|
func (s *PlatformService) SearchDocuments(
|
||||||
|
ctx context.Context,
|
||||||
|
actor *models.Actor,
|
||||||
|
collectionID uuid.UUID,
|
||||||
|
filterParam, orderParam string,
|
||||||
|
limit, offset int32,
|
||||||
|
) ([]db.CoreDocument, int64, error) {
|
||||||
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []any{collectionID}
|
||||||
|
conds, filterArgs, err := buildDocumentFilters(filterParam, len(args)+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
args = append(args, filterArgs...)
|
||||||
|
|
||||||
|
where := "collection_id = $1"
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where += " AND " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
order, err := buildDocumentOrder(orderParam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := s.repo.Pool().QueryRow(ctx,
|
||||||
|
fmt.Sprintf("SELECT COUNT(*) FROM core.documents WHERE %s", where), args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.repo.Pool().Query(ctx, fmt.Sprintf(
|
||||||
|
"SELECT * FROM core.documents WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d",
|
||||||
|
where, order, len(args)-1, len(args)), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []db.CoreDocument
|
||||||
|
for rows.Next() {
|
||||||
|
var d db.CoreDocument
|
||||||
|
if err := rows.Scan(&d.ID, &d.CollectionID, &d.Data, &d.CreatedByUserID, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDocumentFilters(t *testing.T) {
|
||||||
|
t.Run("eq on a nested path uses #>>", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("meta.city.eq.Prague", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
want := "data #>> '{meta,city}' = $2"
|
||||||
|
if conds[0] != want || !reflect.DeepEqual(args, []any{"Prague"}) {
|
||||||
|
t.Fatalf("got %v %v", conds, args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("numeric ops cast to ::numeric", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("age.gt.18,age.lt.65", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(conds[0], "::numeric > $2") || !strings.Contains(conds[1], "::numeric < $3") {
|
||||||
|
t.Fatalf("got %v", conds)
|
||||||
|
}
|
||||||
|
if args[0] != 18.0 || args[1] != 65.0 {
|
||||||
|
t.Fatalf("args: %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("numeric op rejects non-numeric value", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("age.gt.abc", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("like becomes ILIKE", func(t *testing.T) {
|
||||||
|
conds, _, err := buildDocumentFilters("name.like.%_smith%", 2)
|
||||||
|
if err != nil || conds[0] != "data #>> '{name}' ILIKE $2" {
|
||||||
|
t.Fatalf("got %v %v", conds, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("in becomes ANY with a text array", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("role.in.admin|editor", 2)
|
||||||
|
if err != nil || !strings.Contains(conds[0], "= ANY($2)") {
|
||||||
|
t.Fatalf("got %v", conds)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(args[0], []string{"admin", "editor"}) {
|
||||||
|
t.Fatalf("args: %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is null carries no argument", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("deleted_at.is.null", 2)
|
||||||
|
if err != nil || len(args) != 0 || conds[0] != "data #>> '{deleted_at}' IS NULL" {
|
||||||
|
t.Fatalf("got %v %v", conds, args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects injection-shaped field", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("x' OR '1'='1.eq.a", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects term without a known operator", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("age.between.5", 2)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed term errors", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("justafield", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("real columns resolve directly", func(t *testing.T) {
|
||||||
|
conds, _, err := buildDocumentFilters("created_at.gt.2026-01-01", 2)
|
||||||
|
// created_at is a column — but value isn't numeric so gt must fail
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected numeric-required error, got %v", conds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDocumentOrder(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "created_at DESC",
|
||||||
|
"name.asc": "data #>> '{name}' ASC",
|
||||||
|
"name": "data #>> '{name}' ASC",
|
||||||
|
"created_at.desc": "created_at DESC",
|
||||||
|
"a.b.asc,id.desc": "data #>> '{a,b}' ASC, id DESC",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got, err := buildDocumentOrder(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", in, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%q: got %q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A non-asc/desc suffix is a nested field path, not a direction error.
|
||||||
|
if got, err := buildDocumentOrder("name.sideways"); err != nil || got != "data #>> '{name,sideways}' ASC" {
|
||||||
|
t.Errorf("nested order path: got %q err %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := buildDocumentOrder("x;drop.asc"); err == nil {
|
||||||
|
t.Error("expected field error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -849,6 +849,24 @@ paths:
|
|||||||
- $ref: "#/components/parameters/CollectionID"
|
- $ref: "#/components/parameters/CollectionID"
|
||||||
- $ref: "#/components/parameters/PaginationLimit"
|
- $ref: "#/components/parameters/PaginationLimit"
|
||||||
- $ref: "#/components/parameters/PaginationOffset"
|
- $ref: "#/components/parameters/PaginationOffset"
|
||||||
|
- name: filter
|
||||||
|
in: query
|
||||||
|
description: >
|
||||||
|
PostgREST-style filters as comma-separated `field.op.value` terms.
|
||||||
|
Ops: `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (numeric values),
|
||||||
|
`like` (ILIKE), `in` (pipe-separated values, e.g. `role.in.admin|editor`),
|
||||||
|
`is` (`null`|`true`|`false`). Dotted paths index into the document
|
||||||
|
data, e.g. `meta.city.eq.Prague`. Column fields `id`, `created_at`,
|
||||||
|
`updated_at` are also allowed.
|
||||||
|
schema: { type: string }
|
||||||
|
example: "status.eq.active,age.gt.18"
|
||||||
|
- name: order
|
||||||
|
in: query
|
||||||
|
description: >
|
||||||
|
Comma-separated `field.asc|field.desc` list (default `created_at.desc`).
|
||||||
|
Dotted paths index into document data.
|
||||||
|
schema: { type: string }
|
||||||
|
example: "name.asc,created_at.desc"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: List of documents in the collection
|
description: List of documents in the collection
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ project interactively.
|
|||||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||||
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
primora documents list --collection users \
|
||||||
|
--filter "status.eq.active,age.gt.18" --order "age.desc"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--filter` takes comma-separated `field.op.value` terms — ops `eq neq gt gte lt
|
||||||
|
lte like in is`; dotted paths index into document data (`meta.city.eq.Prague`),
|
||||||
|
`id`/`created_at`/`updated_at` are also filterable. `--order` is a
|
||||||
|
`field.asc|field.desc` list.
|
||||||
|
|
||||||
## Vault
|
## Vault
|
||||||
|
|
||||||
A local encrypted vault for secrets the CLI holds - API keys, deployment `.env`
|
A local encrypted vault for secrets the CLI holds - API keys, deployment `.env`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cac } from "cac";
|
|||||||
|
|
||||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||||
import { cmdAuditList } from "./commands/audit.js";
|
import { cmdAuditList } from "./commands/audit.js";
|
||||||
|
import { cmdDocumentsList } from "./commands/documents.js";
|
||||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||||
import { cmdInject } from "./commands/inject.js";
|
import { cmdInject } from "./commands/inject.js";
|
||||||
@@ -156,6 +157,16 @@ cli
|
|||||||
.option("--yes", "Skip the confirmation prompt")
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
.action(cmdJobsRemove);
|
.action(cmdJobsRemove);
|
||||||
|
|
||||||
|
cli
|
||||||
|
.command("documents:list", "List documents in a collection (PostgREST-style --filter/--order)")
|
||||||
|
.option("--collection <slug|id>", "Collection slug or id (required)")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--filter <expr>", "field.op.value terms, comma-separated (eq neq gt gte lt lte like in is)")
|
||||||
|
.option("--order <expr>", "field.asc|field.desc list")
|
||||||
|
.option("--limit <n>", "Page size (default 50)")
|
||||||
|
.option("--offset <n>", "Page offset")
|
||||||
|
.action(cmdDocumentsList);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("audit:list", "List audit events for the current project")
|
.command("audit:list", "List audit events for the current project")
|
||||||
.option("--project <id>", "Project override")
|
.option("--project <id>", "Project override")
|
||||||
@@ -289,6 +300,7 @@ const GROUPS = new Set([
|
|||||||
"projects",
|
"projects",
|
||||||
"buckets",
|
"buckets",
|
||||||
"objects",
|
"objects",
|
||||||
|
"documents",
|
||||||
"keys",
|
"keys",
|
||||||
"jobs",
|
"jobs",
|
||||||
"audit",
|
"audit",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { CollectionsService } from "@primora/api-client";
|
||||||
|
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||||
|
import { formatDate, isJson, printJson, printTable } from "../out.js";
|
||||||
|
|
||||||
|
interface DocumentsListOptions {
|
||||||
|
project?: string;
|
||||||
|
collection?: string;
|
||||||
|
filter?: string;
|
||||||
|
order?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts a collection id or slug.
|
||||||
|
async function collectionIdFor(projectId: string, ref: string): Promise<string> {
|
||||||
|
const { items } = await CollectionsService.listCollections({ projectId });
|
||||||
|
const match = items.find((c) => c.id === ref || c.slug === ref || c.name === ref);
|
||||||
|
if (!match) {
|
||||||
|
throw new CliError(
|
||||||
|
`Collection "${ref}" not found.`,
|
||||||
|
`Known: ${items.map((c) => c.slug).join(", ") || "(none)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdDocumentsList(options: DocumentsListOptions): Promise<void> {
|
||||||
|
if (!options.collection) {
|
||||||
|
throw new CliError("Missing --collection <slug|id>.");
|
||||||
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const collectionId = await collectionIdFor(projectId, options.collection);
|
||||||
|
const res = await CollectionsService.listDocuments({
|
||||||
|
collectionId,
|
||||||
|
limit: options.limit,
|
||||||
|
offset: options.offset,
|
||||||
|
filter: options.filter,
|
||||||
|
order: options.order,
|
||||||
|
});
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.items.length) {
|
||||||
|
console.log("No documents.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
["ID", "DATA", "UPDATED"],
|
||||||
|
res.items.map((d) => [
|
||||||
|
d.id.slice(0, 8),
|
||||||
|
JSON.stringify(d.data).slice(0, 80),
|
||||||
|
formatDate(d.updated_at),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
console.log(`${res.items.length} of ${res.total}`);
|
||||||
|
}
|
||||||
@@ -124,10 +124,22 @@ export class CollectionsService {
|
|||||||
collectionId,
|
collectionId,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset,
|
offset,
|
||||||
|
filter,
|
||||||
|
order,
|
||||||
}: {
|
}: {
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
|
/**
|
||||||
|
* PostgREST-style filters as comma-separated `field.op.value` terms. Ops: `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (numeric values), `like` (ILIKE), `in` (pipe-separated values, e.g. `role.in.admin|editor`), `is` (`null`|`true`|`false`). Dotted paths index into the document data, e.g. `meta.city.eq.Prague`. Column fields `id`, `created_at`, `updated_at` are also allowed.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
filter?: string,
|
||||||
|
/**
|
||||||
|
* Comma-separated `field.asc|field.desc` list (default `created_at.desc`). Dotted paths index into document data.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
order?: string,
|
||||||
}): CancelablePromise<DocumentListResponse> {
|
}): CancelablePromise<DocumentListResponse> {
|
||||||
return __request(OpenAPI, {
|
return __request(OpenAPI, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -138,6 +150,8 @@ export class CollectionsService {
|
|||||||
query: {
|
query: {
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
'offset': offset,
|
'offset': offset,
|
||||||
|
'filter': filter,
|
||||||
|
'order': order,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user