Skip to content

Add the prepared-query API skeleton (Step 2 of #490) - #491

Open
zipdoki wants to merge 1 commit into
feature/per-entity-top-kfrom
feat/prepared-query-api-skeleton
Open

Add the prepared-query API skeleton (Step 2 of #490)#491
zipdoki wants to merge 1 commit into
feature/per-entity-top-kfrom
feat/prepared-query-api-skeleton

Conversation

@zipdoki

@zipdoki zipdoki commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Step 2 of #490. This adds the API surface for prepared queries — endpoints, payloads, and a service declaration — with no implementation behind it. Every service method is TODO("Not yet implemented"), so what I'm asking for is a review of the shape.

Part of #490.

Design notes

Three decisions I'd like checked:

  • A query is registered without a name and gets an id back; a name points at that id separately. That's what lets a new query be checked by its id before it takes traffic, and moved back in one write.
  • An argument declares a type. The body is stored as JSON, so "limit": "{limit}" is text where an Int belongs — the declared type is what makes it a number on the way in.
  • These paths sit under /graph/v3, not under a database. A multi-hop query names its own database per step and may name several.

Changes

  • Add PreparedQueryService with register/amend/get/list/delete, the alias pair, and query — all TODO
  • Add PreparedQueryController for /graph/v3/prepared-queries and /graph/v3/prepared-queries/aliases
  • Take arguments on POST /graph/v3/query/{id}
  • Allow the new read paths through ReadOnlyRequestFilter; POST /graph/v3/query/{id} reads, and its path ends in a variable so no suffix matches

How to Test

./gradlew build

The endpoint-exhaustiveness check in ReadOnlyRequestFilterTest covers the ten new mappings. Nothing else can be tested until the service has a body, so the requests below are the shape under review rather than a transcript.

Register a query, then give it a name:

curl -X POST 'localhost:8080/graph/v3/prepared-queries' \
-H 'Content-Type: application/json' \
-d '{
  "comment": "top purchased items for one user, in one category",
  "arguments": [
    {"name": "entity", "type": "string", "comment": "the user asked about"},
    {"name": "category", "type": "string", "comment": "which ranking to read"},
    {"name": "limit", "type": "int", "comment": "how many ranked rows"},
    {"name": "minMetric", "type": "long", "comment": "drop anything below this"}
  ],
  "fetch": [
    {
      "type": "TOPK",
      "name": "ranked",
      "database": "shop",
      "table": "purchases_table",
      "topk": "top_purchased",
      "entity": {"type": "VALUE", "value": ["{entity}"]},
      "dimensionValues": {"category": "{category}"},
      "limit": "{limit}"
    }
  ],
  "transform": [
    {
      "type": "SQL",
      "name": "result",
      "sql": "SELECT target AS itemId, metric FROM ranked WHERE metric >= {minMetric} ORDER BY metric DESC"
    }
  ]
}'

curl -X POST 'localhost:8080/graph/v3/prepared-queries/aliases' \
-H 'Content-Type: application/json' \
-d '{"alias": "top_grocery", "target": "<id>", "comment": "home screen recommendations"}'

Run it, by the name or by the id:

curl -X POST 'localhost:8080/graph/v3/query/top_grocery' \
-H 'Content-Type: application/json' \
-d '{"arguments": {"entity": "user1", "category": "grocery", "limit": 100, "minMetric": 2}}'
{
  "items": [
    {"name": "result", "data": [{"itemId": "item1", "metric": 2}], "rows": 1, "stats": [], "offset": null, "hasNext": false}
  ]
}

Declared arguments are all required, and a value of the wrong JSON type is read as the declared one, so "limit": "100" binds the same way 100 does.

Reads, moving a name, and editing a query in place
curl 'localhost:8080/graph/v3/prepared-queries?status=ACTIVE'
curl 'localhost:8080/graph/v3/prepared-queries/<id>'
curl 'localhost:8080/graph/v3/prepared-queries/top_grocery'
curl 'localhost:8080/graph/v3/prepared-queries/aliases'
curl 'localhost:8080/graph/v3/prepared-queries/aliases/top_grocery'

{id} takes either form. A name has to satisfy the metadata naming policy and a generated id can't, so the two can never collide and the shape of the path variable says which was meant. Listings answer with a bare array and take ?status=ACTIVE|INACTIVE|ALL, as the table and alias listings do.

A registered query reads back as it was written, placeholders and all — what's stored is a template, not a query with values in it:

{
  "tenant": "local",
  "id": "3f2c8a91-7d4e-4b12-9c33-0a51e6b8d204",
  "arguments": [
    {"name": "entity", "type": "string", "comment": "the user asked about", "nullable": false},
    {"name": "limit", "type": "int", "comment": "how many ranked rows", "nullable": false}
  ],
  "fetch": [{"type": "TOPK", "name": "ranked", "database": "shop", "table": "purchases_table", "topk": "top_purchased", "entity": {"type": "VALUE", "value": ["{entity}"]}, "limit": "{limit}", "...": "..."}],
  "transform": [{"type": "SQL", "name": "result", "sql": "SELECT target AS itemId, metric FROM ranked WHERE metric >= {minMetric} ORDER BY metric DESC"}],
  "stats": [],
  "active": true,
  "comment": "top purchased items for one user, in one category"
}

The response also carries the revision/createdAt/createdBy/updatedAt/updatedBy fields every other v3 descriptor does, at their defaults.

Moving a name is one call, and sending the old id back is the rollback:

curl -X PUT 'localhost:8080/graph/v3/prepared-queries/aliases/top_grocery' \
-H 'Content-Type: application/json' \
-d '{"target": "<other-id>"}'

Editing a query leaves the parts that weren't sent as they were:

curl -X PUT 'localhost:8080/graph/v3/prepared-queries/<id>' \
-H 'Content-Type: application/json' \
-d '{"transform": [{"type": "SQL", "name": "result", "sql": "SELECT target AS itemId FROM ranked WHERE metric >= {minMetric}"}]}'

Not touched

Storage, and the two decisions recorded in #490 — where these rows live, and how fast a moved name propagates. POST /graph/v3/query also still takes a query with no arguments: that needs binding which doesn't exist yet, and declaring the field before it works would be a lie.

AI Assistance

  • This PR was written largely with AI assistance.
    • Tool / model: Claude Code (Opus 5)

@zipdoki zipdoki self-assigned this Aug 12, 2026
@zipdoki
zipdoki requested a review from em3s as a code owner August 12, 2026 08:56
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Aug 12, 2026
@zipdoki
zipdoki removed the request for review from em3s August 12, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant