Feat v0.7.11 query http ergonomics (#65)
All checks were successful
CI/CD / test-frontend (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m53s
CI/CD / test-sqlite (push) Successful in 2m57s
CI/CD / build-and-deploy (push) Successful in 1m13s
CI/CD / detect-changes (push) Successful in 3s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #65.
This commit is contained in:
2026-04-02 23:32:17 +00:00
committed by xcaliber
parent f06c6c954b
commit c2d52f50c5
8 changed files with 924 additions and 77 deletions

View File

@@ -2,12 +2,15 @@
//
//
// Permissions:
// db.read → db.query(), db.view(), db.list_tables()
// db.read → db.query(), db.count(), db.aggregate(), db.query_batch(), db.view(), db.list_tables()
// db.write → all of the above + db.insert(), db.update(), db.delete()
//
// Starlark API:
//
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50, before={"created_at": "2026-01-01"}, after={"count": 5}, search_like={"title": "%hello%"})
// n = db.count("logs", filters={"user_id": "abc"})
// val = db.aggregate("orders", "amount", "sum", filters={"status": "paid"})
// batch = db.query_batch([{"table": "logs", "filters": {"user_id": "abc"}}, {"table": "tasks"}])
// row = db.insert("logs", {"message": "hello"})
// ok = db.update("logs", row_id, {"message": "updated"})
// ok = db.delete("logs", row_id)
@@ -53,6 +56,9 @@ var allowedViews = map[string]bool{
func BuildDBModule(ctx context.Context, cfg DBModuleConfig) *starlarkstruct.Module {
fns := starlark.StringDict{
"query": starlark.NewBuiltin("db.query", dbQuery(ctx, cfg)),
"count": starlark.NewBuiltin("db.count", dbCount(ctx, cfg)),
"aggregate": starlark.NewBuiltin("db.aggregate", dbAggregate(ctx, cfg)),
"query_batch": starlark.NewBuiltin("db.query_batch", dbQueryBatch(ctx, cfg)),
"view": starlark.NewBuiltin("db.view", dbView(ctx, cfg)),
"list_tables": starlark.NewBuiltin("db.list_tables", dbListTables(ctx, cfg)),
}
@@ -307,6 +313,97 @@ func (cfg DBModuleConfig) starlarkSearchLikeToSQL(searchVal starlark.Value, star
return "(" + strings.Join(parts, " OR ") + ")", args, nil
}
// querySpec holds parsed parameters for a single SELECT query.
type querySpec struct {
table string
filters starlark.Value // None or *Dict
order starlark.Value // None or String
limit int64
before starlark.Value
after starlark.Value
searchLike starlark.Value
}
// buildSelectQuery constructs a "SELECT * FROM ... WHERE ... ORDER BY ... LIMIT ..."
// SQL statement from a querySpec. Returns the SQL string and args slice.
// Reused by dbQuery and dbQueryBatch.
func (cfg DBModuleConfig) buildSelectQuery(spec querySpec) (string, []any, error) {
physTable, err := cfg.physicalTable(spec.table)
if err != nil {
return "", nil, err
}
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(spec.filters, 1)
if err != nil {
return "", nil, err
}
beforeParts, beforeArgs, err := cfg.starlarkRangeToSQL(spec.before, "<", len(whereArgs)+1)
if err != nil {
return "", nil, err
}
afterParts, afterArgs, err := cfg.starlarkRangeToSQL(spec.after, ">", len(whereArgs)+len(beforeArgs)+1)
if err != nil {
return "", nil, err
}
searchClause, searchArgs, err := cfg.starlarkSearchLikeToSQL(spec.searchLike, len(whereArgs)+len(beforeArgs)+len(afterArgs)+1)
if err != nil {
return "", nil, err
}
var allParts []string
var allArgs []any
if whereClause != "" {
allParts = append(allParts, strings.TrimPrefix(whereClause, "WHERE "))
allArgs = append(allArgs, whereArgs...)
} else {
allArgs = append(allArgs, whereArgs...)
}
allParts = append(allParts, beforeParts...)
allArgs = append(allArgs, beforeArgs...)
allParts = append(allParts, afterParts...)
allArgs = append(allArgs, afterArgs...)
if searchClause != "" {
allParts = append(allParts, searchClause)
allArgs = append(allArgs, searchArgs...)
}
lim := spec.limit
if lim < 1 || lim > 1000 {
lim = 100
}
query := fmt.Sprintf("SELECT * FROM %s", physTable)
if len(allParts) > 0 {
query += " WHERE " + strings.Join(allParts, " AND ")
}
if spec.order != starlark.None && spec.order != nil {
col, ok := spec.order.(starlark.String)
if !ok {
return "", nil, fmt.Errorf("db.query: order must be a string column name")
}
colStr := string(col)
dir := "ASC"
if strings.HasPrefix(colStr, "-") {
colStr = colStr[1:]
dir = "DESC"
}
if strings.ContainsAny(colStr, " \t\n\"';") {
return "", nil, fmt.Errorf("db.query: invalid order column %q", colStr)
}
query += fmt.Sprintf(" ORDER BY %s %s", colStr, dir)
}
limitPH := cfg.ph(len(allArgs) + 1)
query += " LIMIT " + limitPH
allArgs = append(allArgs, lim)
return query, allArgs, nil
}
// dbQuery implements db.query(table, filters=None, order=None, limit=100, before=None, after=None, search_like=None).
func dbQuery(ctx context.Context, cfg DBModuleConfig) 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) {
@@ -330,6 +427,48 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
return nil, err
}
lim, ok := limit.Int64()
if !ok || lim < 1 || lim > 1000 {
lim = 100
}
query, allArgs, err := cfg.buildSelectQuery(querySpec{
table: table,
filters: filters,
order: order,
limit: lim,
before: before,
after: after,
searchLike: searchLike,
})
if err != nil {
return nil, err
}
rows, err := cfg.DB.QueryContext(ctx, query, allArgs...)
if err != nil {
return nil, fmt.Errorf("db.query: %w", err)
}
defer rows.Close()
return rowsToStarlark(rows)
}
}
// dbCount implements db.count(table, filters={}).
// Returns the integer count of rows matching the given filters.
func dbCount(ctx context.Context, cfg DBModuleConfig) 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 table string
var filters starlark.Value = starlark.None
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"table", &table,
"filters?", &filters,
); err != nil {
return nil, err
}
physTable, err := cfg.physicalTable(table)
if err != nil {
return nil, err
@@ -340,83 +479,194 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
return nil, err
}
// Build range clauses (before → <, after → >)
beforeParts, beforeArgs, err := cfg.starlarkRangeToSQL(before, "<", len(whereArgs)+1)
if err != nil {
return nil, err
}
afterParts, afterArgs, err := cfg.starlarkRangeToSQL(after, ">", len(whereArgs)+len(beforeArgs)+1)
if err != nil {
return nil, err
}
searchClause, searchArgs, err := cfg.starlarkSearchLikeToSQL(searchLike, len(whereArgs)+len(beforeArgs)+len(afterArgs)+1)
if err != nil {
return nil, err
}
// Merge all WHERE conditions
var allParts []string
var allArgs []any
// Extract equality parts from whereClause
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", physTable)
if whereClause != "" {
// whereClause is "WHERE x = $1 AND y = $2"; strip the "WHERE " prefix
allParts = append(allParts, strings.TrimPrefix(whereClause, "WHERE "))
allArgs = append(allArgs, whereArgs...)
} else {
allArgs = append(allArgs, whereArgs...)
}
allParts = append(allParts, beforeParts...)
allArgs = append(allArgs, beforeArgs...)
allParts = append(allParts, afterParts...)
allArgs = append(allArgs, afterArgs...)
if searchClause != "" {
allParts = append(allParts, searchClause)
allArgs = append(allArgs, searchArgs...)
query += " " + whereClause
}
lim, ok := limit.Int64()
if !ok || lim < 1 || lim > 1000 {
lim = 100
var count int64
if err := cfg.DB.QueryRowContext(ctx, query, whereArgs...).Scan(&count); err != nil {
return nil, fmt.Errorf("db.count: %w", err)
}
query := fmt.Sprintf("SELECT * FROM %s", physTable)
if len(allParts) > 0 {
query += " WHERE " + strings.Join(allParts, " AND ")
}
if order != starlark.None {
col, ok := order.(starlark.String)
if !ok {
return nil, fmt.Errorf("db.query: order must be a string column name")
}
colStr := string(col)
dir := "ASC"
if strings.HasPrefix(colStr, "-") {
colStr = colStr[1:]
dir = "DESC"
}
if strings.ContainsAny(colStr, " \t\n\"';") {
return nil, fmt.Errorf("db.query: invalid order column %q", colStr)
}
query += fmt.Sprintf(" ORDER BY %s %s", colStr, dir)
}
limitPH := cfg.ph(len(allArgs) + 1)
query += " LIMIT " + limitPH
allArgs = append(allArgs, lim)
rows, err := cfg.DB.QueryContext(ctx, query, allArgs...)
if err != nil {
return nil, fmt.Errorf("db.query: %w", err)
}
defer rows.Close()
return rowsToStarlark(rows)
return starlark.MakeInt64(count), nil
}
}
// allowedAggOps is the set of valid aggregation operations.
var allowedAggOps = map[string]bool{
"count": true,
"sum": true,
"avg": true,
"min": true,
"max": true,
}
// dbAggregate implements db.aggregate(table, column, op, filters={}).
// op must be one of: count, sum, avg, min, max.
// Returns int, float, or None (if no matching rows).
func dbAggregate(ctx context.Context, cfg DBModuleConfig) 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 table, column, op string
var filters starlark.Value = starlark.None
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"table", &table,
"column", &column,
"op", &op,
"filters?", &filters,
); err != nil {
return nil, err
}
if !allowedAggOps[op] {
return nil, fmt.Errorf("db.aggregate: invalid op %q (allowed: count, sum, avg, min, max)", op)
}
if strings.ContainsAny(column, " \t\n\"';-") {
return nil, fmt.Errorf("db.aggregate: invalid column name %q", column)
}
physTable, err := cfg.physicalTable(table)
if err != nil {
return nil, err
}
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
if err != nil {
return nil, err
}
query := fmt.Sprintf("SELECT %s(%s) FROM %s", strings.ToUpper(op), column, physTable)
if whereClause != "" {
query += " " + whereClause
}
var result sql.NullFloat64
if err := cfg.DB.QueryRowContext(ctx, query, whereArgs...).Scan(&result); err != nil {
return nil, fmt.Errorf("db.aggregate: %w", err)
}
if !result.Valid {
return starlark.None, nil
}
// Return int if the value is a whole number.
if result.Float64 == float64(int64(result.Float64)) {
return starlark.MakeInt64(int64(result.Float64)), nil
}
return starlark.Float(result.Float64), nil
}
}
// dbQueryBatch implements db.query_batch(queries).
// Each element in the queries list is a dict with keys:
//
// table (required), filters, order, limit, before, after, search_like (all optional).
//
// Returns a list of result lists — one per query spec.
func dbQueryBatch(ctx context.Context, cfg DBModuleConfig) 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 queries *starlark.List
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"queries", &queries,
); err != nil {
return nil, err
}
n := queries.Len()
if n == 0 {
return nil, fmt.Errorf("db.query_batch: queries list is empty")
}
if n > 10 {
return nil, fmt.Errorf("db.query_batch: max 10 queries, got %d", n)
}
var results []starlark.Value
for i := 0; i < n; i++ {
spec, err := dictToQuerySpec(queries.Index(i))
if err != nil {
return nil, fmt.Errorf("db.query_batch[%d]: %w", i, err)
}
query, queryArgs, err := cfg.buildSelectQuery(spec)
if err != nil {
return nil, fmt.Errorf("db.query_batch[%d]: %w", i, err)
}
rows, err := cfg.DB.QueryContext(ctx, query, queryArgs...)
if err != nil {
return nil, fmt.Errorf("db.query_batch[%d]: %w", i, err)
}
resultList, err := rowsToStarlark(rows)
rows.Close()
if err != nil {
return nil, fmt.Errorf("db.query_batch[%d]: %w", i, err)
}
results = append(results, resultList)
}
return starlark.NewList(results), nil
}
}
// dictToQuerySpec extracts query parameters from a Starlark dict.
func dictToQuerySpec(v starlark.Value) (querySpec, error) {
d, ok := v.(*starlark.Dict)
if !ok {
return querySpec{}, fmt.Errorf("each query must be a dict, got %s", v.Type())
}
spec := querySpec{
filters: starlark.None,
order: starlark.None,
limit: 100,
before: starlark.None,
after: starlark.None,
searchLike: starlark.None,
}
tableVal, found, err := d.Get(starlark.String("table"))
if err != nil {
return querySpec{}, err
}
if !found || tableVal == starlark.None {
return querySpec{}, fmt.Errorf("missing required key \"table\"")
}
tableStr, ok := starlark.AsString(tableVal)
if !ok {
return querySpec{}, fmt.Errorf("\"table\" must be a string, got %s", tableVal.Type())
}
spec.table = tableStr
if v, found, _ := d.Get(starlark.String("filters")); found && v != starlark.None {
spec.filters = v
}
if v, found, _ := d.Get(starlark.String("order")); found && v != starlark.None {
spec.order = v
}
if v, found, _ := d.Get(starlark.String("limit")); found && v != starlark.None {
if limInt, ok := v.(starlark.Int); ok {
lim, _ := limInt.Int64()
spec.limit = lim
}
}
if v, found, _ := d.Get(starlark.String("before")); found && v != starlark.None {
spec.before = v
}
if v, found, _ := d.Get(starlark.String("after")); found && v != starlark.None {
spec.after = v
}
if v, found, _ := d.Get(starlark.String("search_like")); found && v != starlark.None {
spec.searchLike = v
}
return spec, nil
}
// dbView implements db.view(view_name, filters=None, limit=100).
// Queries ext_view_{view_name} — only allowedViews are permitted.
func dbView(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {