Feat v0.7.11 query & HTTP ergonomics
Some checks failed
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Has been cancelled
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-runners (pull_request) Has been skipped

Add four new Starlark sandbox builtins to reduce extension friction
around SQL decomposition and synchronous HTTP fan-out:

- db.count(table, filters) — integer row count
- db.aggregate(table, column, op, filters) — single-value aggregation
- db.query_batch(queries) — up to 10 query specs in one call
- http.batch(requests) — concurrent dispatch of up to 10 HTTP requests

Extract buildSelectQuery helper from dbQuery for reuse by query_batch.
21 new tests (14 db, 7 http). No new permissions or schema changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:31:52 +00:00
parent f06c6c954b
commit f036995d5c
8 changed files with 924 additions and 77 deletions

View File

@@ -9,6 +9,7 @@
// resp = http.put(url, body="", headers={})
// resp = http.delete(url, headers={})
// resp = http.request(method, url, body="", headers={})
// resps = http.batch([{"method": "GET", "url": "..."}, ...])
//
// # resp is a dict: {"status": 200, "headers": {...}, "body": "..."}
//
@@ -37,6 +38,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"syscall"
"time"
@@ -230,9 +232,125 @@ func BuildHTTPModule(ctx context.Context, cfg HTTPModuleConfig) *starlarkstruct.
}
return doRequest("DELETE", rawURL, "", dictToStringMap(hdrs))
}),
"batch": starlark.NewBuiltin("http.batch", httpBatch(ctx, ac, cfg.timeout(), cfg.maxBody())),
})
}
// ─── Batch Dispatch ─────────────────────────
// httpBatch implements http.batch(requests).
// requests is a list of dicts, each with keys: method, url, body?, headers?.
// Dispatches all requests concurrently (max 10). Individual failures return
// error response dicts rather than aborting the batch.
func httpBatch(
ctx context.Context,
ac *accessControl,
timeout time.Duration,
maxBody int64,
) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var requests *starlark.List
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"requests", &requests,
); err != nil {
return nil, err
}
n := requests.Len()
if n == 0 {
return nil, fmt.Errorf("http.batch: requests list is empty")
}
if n > 10 {
return nil, fmt.Errorf("http.batch: max 10 requests, got %d", n)
}
// Parse all request specs before dispatching (fail fast on bad input).
type reqSpec struct {
method, url, body string
headers map[string]string
}
specs := make([]reqSpec, n)
for i := 0; i < n; i++ {
d, ok := requests.Index(i).(*starlark.Dict)
if !ok {
return nil, fmt.Errorf("http.batch[%d]: each request must be a dict, got %s", i, requests.Index(i).Type())
}
method, url, body, headers, err := parseBatchRequestDict(d)
if err != nil {
return nil, fmt.Errorf("http.batch[%d]: %w", i, err)
}
specs[i] = reqSpec{method: method, url: url, body: body, headers: headers}
}
// Dispatch concurrently.
results := make([]starlark.Value, n)
var wg sync.WaitGroup
for i, spec := range specs {
wg.Add(1)
go func(idx int, s reqSpec) {
defer wg.Done()
resp, err := executeHTTPRequest(ctx, ac, s.method, s.url, s.body, s.headers, timeout, maxBody)
if err != nil {
results[idx] = buildErrorResponseDict(err.Error())
} else {
results[idx] = resp
}
}(i, spec)
}
wg.Wait()
return starlark.NewList(results), nil
}
}
// parseBatchRequestDict extracts method, url, body, headers from a Starlark dict.
func parseBatchRequestDict(d *starlark.Dict) (method, rawURL, body string, headers map[string]string, err error) {
methodVal, found, _ := d.Get(starlark.String("method"))
if !found || methodVal == starlark.None {
return "", "", "", nil, fmt.Errorf("missing required key \"method\"")
}
method, ok := starlark.AsString(methodVal)
if !ok {
return "", "", "", nil, fmt.Errorf("\"method\" must be a string, got %s", methodVal.Type())
}
method = strings.ToUpper(method)
urlVal, found, _ := d.Get(starlark.String("url"))
if !found || urlVal == starlark.None {
return "", "", "", nil, fmt.Errorf("missing required key \"url\"")
}
rawURL, ok = starlark.AsString(urlVal)
if !ok {
return "", "", "", nil, fmt.Errorf("\"url\" must be a string, got %s", urlVal.Type())
}
if bodyVal, found, _ := d.Get(starlark.String("body")); found && bodyVal != starlark.None {
body, _ = starlark.AsString(bodyVal)
}
if hdrsVal, found, _ := d.Get(starlark.String("headers")); found && hdrsVal != starlark.None {
if hdrsDict, ok := hdrsVal.(*starlark.Dict); ok {
headers = dictToStringMap(hdrsDict)
}
}
return method, rawURL, body, headers, nil
}
// buildErrorResponseDict creates an error response dict for a failed batch request.
func buildErrorResponseDict(errMsg string) starlark.Value {
resp := starlark.NewDict(3)
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(0))
_ = resp.SetKey(starlark.String("headers"), starlark.NewDict(0))
_ = resp.SetKey(starlark.String("body"), starlark.String("error: "+errMsg))
return resp
}
// ─── Request Execution ──────────────────────
func executeHTTPRequest(