Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Conversation search (db.query search_like, chat-core /search endpoint, sidebar search UI), message pagination polish (scroll preservation, loading spinner), workflow-chat integration package, and multi-user E2E test infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
681 lines
20 KiB
Go
681 lines
20 KiB
Go
// Package sandbox — db_module.go
|
|
//
|
|
// v0.29.2 CS1: Structured db module for Starlark extensions.
|
|
//
|
|
// Permissions:
|
|
// db.read → db.query(), 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%"})
|
|
// row = db.insert("logs", {"message": "hello"})
|
|
// ok = db.update("logs", row_id, {"message": "updated"})
|
|
// ok = db.delete("logs", row_id)
|
|
// names = db.list_tables()
|
|
// rows = db.view("users", filters={"id": "abc"})
|
|
//
|
|
// All table access is scoped to ext_{package_id}_{table_name}.
|
|
// Platform views (ext_view_users, ext_view_channels) are accessible
|
|
// via db.view() and are read-only regardless of permission level.
|
|
//
|
|
// Queries are parameterized — no raw SQL is ever accepted from scripts.
|
|
// Dialect differences (placeholder style) are handled internally.
|
|
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"go.starlark.net/starlark"
|
|
"go.starlark.net/starlarkstruct"
|
|
)
|
|
|
|
// DBModuleConfig holds configuration for a db module instance.
|
|
type DBModuleConfig struct {
|
|
PackageID string
|
|
CanWrite bool // true when db.write permission is granted
|
|
DB *sql.DB
|
|
IsPostgres bool
|
|
}
|
|
|
|
// allowedViews is the set of platform views extensions may query via db.view().
|
|
var allowedViews = map[string]bool{
|
|
"users": true,
|
|
"channels": true,
|
|
}
|
|
|
|
// BuildDBModule creates the "db" starlark module for an extension.
|
|
// The module enforces namespace isolation — all physical table names are
|
|
// prefixed with ext_{packageID}_. Write operations are guarded by CanWrite.
|
|
func BuildDBModule(ctx context.Context, cfg DBModuleConfig) *starlarkstruct.Module {
|
|
fns := starlark.StringDict{
|
|
"query": starlark.NewBuiltin("db.query", dbQuery(ctx, cfg)),
|
|
"view": starlark.NewBuiltin("db.view", dbView(ctx, cfg)),
|
|
"list_tables": starlark.NewBuiltin("db.list_tables", dbListTables(ctx, cfg)),
|
|
}
|
|
|
|
if cfg.CanWrite {
|
|
fns["insert"] = starlark.NewBuiltin("db.insert", dbInsert(ctx, cfg))
|
|
fns["update"] = starlark.NewBuiltin("db.update", dbUpdate(ctx, cfg))
|
|
fns["delete"] = starlark.NewBuiltin("db.delete", dbDelete(ctx, cfg))
|
|
}
|
|
|
|
return MakeModule("db", fns)
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────
|
|
|
|
// physicalTable returns the fully-qualified table name for an extension table.
|
|
func (cfg DBModuleConfig) physicalTable(logicalName string) (string, error) {
|
|
if logicalName == "" {
|
|
return "", fmt.Errorf("db: table name must not be empty")
|
|
}
|
|
if strings.ContainsAny(logicalName, " \t\n\"';-") {
|
|
return "", fmt.Errorf("db: invalid table name %q", logicalName)
|
|
}
|
|
// Sanitize package ID for use in identifiers (replace - with _)
|
|
pkgSlug := strings.ReplaceAll(cfg.PackageID, "-", "_")
|
|
return fmt.Sprintf("ext_%s_%s", pkgSlug, logicalName), nil
|
|
}
|
|
|
|
// ph returns the parameter placeholder for the nth argument (1-based).
|
|
func (cfg DBModuleConfig) ph(n int) string {
|
|
if cfg.IsPostgres {
|
|
return fmt.Sprintf("$%d", n)
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
// generateID produces a random hex ID for new rows.
|
|
func generateID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// starlarkFiltersToSQL converts a Starlark dict of {col: val} into a
|
|
// WHERE clause and argument slice. Returns empty string if filters is None.
|
|
func (cfg DBModuleConfig) starlarkFiltersToSQL(filters starlark.Value, startIdx int) (string, []any, error) {
|
|
if filters == starlark.None || filters == nil {
|
|
return "", nil, nil
|
|
}
|
|
d, ok := filters.(*starlark.Dict)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("db: filters must be a dict, got %s", filters.Type())
|
|
}
|
|
if d.Len() == 0 {
|
|
return "", nil, nil
|
|
}
|
|
|
|
var parts []string
|
|
var args []any
|
|
idx := startIdx
|
|
|
|
for _, item := range d.Items() {
|
|
col, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("db: filter key must be a string, got %s", item[0].Type())
|
|
}
|
|
colStr := string(col)
|
|
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
|
return "", nil, fmt.Errorf("db: invalid column name %q", colStr)
|
|
}
|
|
|
|
val, err := starlarkToGoValue(item[1])
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("db: filter value for %q: %w", colStr, err)
|
|
}
|
|
|
|
parts = append(parts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
|
|
args = append(args, val)
|
|
idx++
|
|
}
|
|
|
|
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
|
}
|
|
|
|
// starlarkToGoValue converts a Starlark value to a Go value suitable for SQL.
|
|
func starlarkToGoValue(v starlark.Value) (any, error) {
|
|
switch val := v.(type) {
|
|
case starlark.String:
|
|
return string(val), nil
|
|
case starlark.Int:
|
|
i, ok := val.Int64()
|
|
if !ok {
|
|
return nil, fmt.Errorf("integer overflow")
|
|
}
|
|
return i, nil
|
|
case starlark.Float:
|
|
return float64(val), nil
|
|
case starlark.Bool:
|
|
return bool(val), nil
|
|
case starlark.NoneType:
|
|
return nil, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported type %s", v.Type())
|
|
}
|
|
}
|
|
|
|
// rowsToStarlark converts sql.Rows to a Starlark list of dicts.
|
|
func rowsToStarlark(rows *sql.Rows) (*starlark.List, error) {
|
|
cols, err := rows.Columns()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result []starlark.Value
|
|
for rows.Next() {
|
|
vals := make([]any, len(cols))
|
|
ptrs := make([]any, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d := starlark.NewDict(len(cols))
|
|
for i, col := range cols {
|
|
sv, err := goToStarlark(vals[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("column %q: %w", col, err)
|
|
}
|
|
if err := d.SetKey(starlark.String(col), sv); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
result = append(result, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return starlark.NewList(result), nil
|
|
}
|
|
|
|
// goToStarlark converts a Go value (from sql.Scan) to a Starlark value.
|
|
func goToStarlark(v any) (starlark.Value, error) {
|
|
if v == nil {
|
|
return starlark.None, nil
|
|
}
|
|
switch val := v.(type) {
|
|
case string:
|
|
return starlark.String(val), nil
|
|
case []byte:
|
|
return starlark.String(string(val)), nil
|
|
case int64:
|
|
return starlark.MakeInt64(val), nil
|
|
case float64:
|
|
return starlark.Float(val), nil
|
|
case bool:
|
|
return starlark.Bool(val), nil
|
|
default:
|
|
// Fallback: stringify
|
|
return starlark.String(fmt.Sprintf("%v", val)), nil
|
|
}
|
|
}
|
|
|
|
// ── Builtins ─────────────────────────────────
|
|
|
|
// starlarkRangeToSQL converts a Starlark dict of {col: val} into range
|
|
// comparison clauses (e.g. col < $N or col > $N). op must be "<" or ">".
|
|
func (cfg DBModuleConfig) starlarkRangeToSQL(rangeVal starlark.Value, op string, startIdx int) ([]string, []any, error) {
|
|
if rangeVal == starlark.None || rangeVal == nil {
|
|
return nil, nil, nil
|
|
}
|
|
d, ok := rangeVal.(*starlark.Dict)
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("db: range param must be a dict, got %s", rangeVal.Type())
|
|
}
|
|
if d.Len() == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
var parts []string
|
|
var args []any
|
|
idx := startIdx
|
|
|
|
for _, item := range d.Items() {
|
|
col, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("db: range key must be a string, got %s", item[0].Type())
|
|
}
|
|
colStr := string(col)
|
|
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
|
return nil, nil, fmt.Errorf("db: invalid column name %q", colStr)
|
|
}
|
|
|
|
val, err := starlarkToGoValue(item[1])
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("db: range value for %q: %w", colStr, err)
|
|
}
|
|
|
|
parts = append(parts, fmt.Sprintf("%s %s %s", colStr, op, cfg.ph(idx)))
|
|
args = append(args, val)
|
|
idx++
|
|
}
|
|
|
|
return parts, args, nil
|
|
}
|
|
|
|
// starlarkSearchLikeToSQL converts a Starlark dict of {col: pattern} into
|
|
// a parenthesized OR group of LIKE (SQLite) / ILIKE (Postgres) clauses.
|
|
// Example: {"title": "%hello%", "content": "%hello%"} → (title ILIKE $1 OR content ILIKE $2)
|
|
func (cfg DBModuleConfig) starlarkSearchLikeToSQL(searchVal starlark.Value, startIdx int) (string, []any, error) {
|
|
if searchVal == starlark.None || searchVal == nil {
|
|
return "", nil, nil
|
|
}
|
|
d, ok := searchVal.(*starlark.Dict)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("db: search_like must be a dict, got %s", searchVal.Type())
|
|
}
|
|
if d.Len() == 0 {
|
|
return "", nil, nil
|
|
}
|
|
|
|
var parts []string
|
|
var args []any
|
|
idx := startIdx
|
|
|
|
op := "LIKE" // SQLite — case-insensitive for ASCII by default
|
|
if cfg.IsPostgres {
|
|
op = "ILIKE" // Postgres — explicit case-insensitive
|
|
}
|
|
|
|
for _, item := range d.Items() {
|
|
col, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("db: search_like key must be a string, got %s", item[0].Type())
|
|
}
|
|
colStr := string(col)
|
|
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
|
return "", nil, fmt.Errorf("db: invalid column name %q", colStr)
|
|
}
|
|
|
|
val, err := starlarkToGoValue(item[1])
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("db: search_like value for %q: %w", colStr, err)
|
|
}
|
|
|
|
parts = append(parts, fmt.Sprintf("%s %s %s", colStr, op, cfg.ph(idx)))
|
|
args = append(args, val)
|
|
idx++
|
|
}
|
|
|
|
return "(" + strings.Join(parts, " OR ") + ")", args, 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) {
|
|
var table string
|
|
var filters starlark.Value = starlark.None
|
|
var order starlark.Value = starlark.None
|
|
var limit starlark.Int = starlark.MakeInt(100)
|
|
var before starlark.Value = starlark.None
|
|
var after starlark.Value = starlark.None
|
|
var searchLike starlark.Value = starlark.None
|
|
|
|
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
|
"table", &table,
|
|
"filters?", &filters,
|
|
"order?", &order,
|
|
"limit?", &limit,
|
|
"before?", &before,
|
|
"after?", &after,
|
|
"search_like?", &searchLike,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
physTable, err := cfg.physicalTable(table)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
|
|
if err != nil {
|
|
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
|
|
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...)
|
|
}
|
|
|
|
lim, ok := limit.Int64()
|
|
if !ok || lim < 1 || lim > 1000 {
|
|
lim = 100
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
|
var viewName string
|
|
var filters starlark.Value = starlark.None
|
|
var limit starlark.Int = starlark.MakeInt(100)
|
|
|
|
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
|
"view_name", &viewName,
|
|
"filters?", &filters,
|
|
"limit?", &limit,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !allowedViews[viewName] {
|
|
return nil, fmt.Errorf("db.view: unknown view %q (allowed: users, channels)", viewName)
|
|
}
|
|
|
|
physView := "ext_view_" + viewName
|
|
|
|
whereClause, whereArgs, err := cfg.starlarkFiltersToSQL(filters, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lim, ok := limit.Int64()
|
|
if !ok || lim < 1 || lim > 1000 {
|
|
lim = 100
|
|
}
|
|
|
|
query := fmt.Sprintf("SELECT * FROM %s", physView)
|
|
if whereClause != "" {
|
|
query += " " + whereClause
|
|
}
|
|
limitPH := cfg.ph(len(whereArgs) + 1)
|
|
query += " LIMIT " + limitPH
|
|
whereArgs = append(whereArgs, lim)
|
|
|
|
rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.view: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return rowsToStarlark(rows)
|
|
}
|
|
}
|
|
|
|
// dbListTables implements db.list_tables() → list of logical table names.
|
|
func dbListTables(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) {
|
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prefix := "ext_" + strings.ReplaceAll(cfg.PackageID, "-", "_") + "_"
|
|
var query string
|
|
var queryArgs []any
|
|
|
|
if cfg.IsPostgres {
|
|
query = `SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name LIKE $1
|
|
ORDER BY table_name`
|
|
queryArgs = []any{prefix + "%"}
|
|
} else {
|
|
query = `SELECT name FROM sqlite_master
|
|
WHERE type = 'table' AND name LIKE ?
|
|
ORDER BY name`
|
|
queryArgs = []any{prefix + "%"}
|
|
}
|
|
|
|
rows, err := cfg.DB.QueryContext(ctx, query, queryArgs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.list_tables: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var names []starlark.Value
|
|
for rows.Next() {
|
|
var physName string
|
|
if err := rows.Scan(&physName); err != nil {
|
|
return nil, err
|
|
}
|
|
// Strip prefix to return logical name
|
|
logical := strings.TrimPrefix(physName, prefix)
|
|
names = append(names, starlark.String(logical))
|
|
}
|
|
if names == nil {
|
|
names = []starlark.Value{}
|
|
}
|
|
return starlark.NewList(names), nil
|
|
}
|
|
}
|
|
|
|
// dbInsert implements db.insert(table, row_dict) → inserted row dict with id.
|
|
func dbInsert(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 rowVal starlark.Value
|
|
|
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowVal); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
row, ok := rowVal.(*starlark.Dict)
|
|
if !ok {
|
|
return nil, fmt.Errorf("db.insert: row must be a dict, got %s", rowVal.Type())
|
|
}
|
|
|
|
physTable, err := cfg.physicalTable(table)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := generateID()
|
|
cols := []string{"id"}
|
|
placeholders := []string{cfg.ph(1)}
|
|
vals := []any{id}
|
|
idx := 2
|
|
|
|
for _, item := range row.Items() {
|
|
col, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return nil, fmt.Errorf("db.insert: column key must be a string")
|
|
}
|
|
colStr := string(col)
|
|
if colStr == "id" || colStr == "created_at" {
|
|
continue // auto-managed
|
|
}
|
|
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
|
return nil, fmt.Errorf("db.insert: invalid column name %q", colStr)
|
|
}
|
|
val, err := starlarkToGoValue(item[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.insert: column %q: %w", colStr, err)
|
|
}
|
|
cols = append(cols, colStr)
|
|
placeholders = append(placeholders, cfg.ph(idx))
|
|
vals = append(vals, val)
|
|
idx++
|
|
}
|
|
|
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
|
physTable,
|
|
strings.Join(cols, ", "),
|
|
strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
|
|
return nil, fmt.Errorf("db.insert: %w", err)
|
|
}
|
|
|
|
// Return the inserted row (re-query by id)
|
|
selectQ := fmt.Sprintf("SELECT * FROM %s WHERE id = %s", physTable, cfg.ph(1))
|
|
rows, err := cfg.DB.QueryContext(ctx, selectQ, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.insert: re-query failed: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
list, err := rowsToStarlark(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if list.Len() == 0 {
|
|
return starlark.None, nil
|
|
}
|
|
return list.Index(0), nil
|
|
}
|
|
}
|
|
|
|
// dbUpdate implements db.update(table, id, partial_dict) → True.
|
|
func dbUpdate(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, rowID string
|
|
var patchVal starlark.Value
|
|
|
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 3, &table, &rowID, &patchVal); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
patch, ok := patchVal.(*starlark.Dict)
|
|
if !ok {
|
|
return nil, fmt.Errorf("db.update: patch must be a dict, got %s", patchVal.Type())
|
|
}
|
|
if patch.Len() == 0 {
|
|
return starlark.True, nil // nothing to do
|
|
}
|
|
|
|
physTable, err := cfg.physicalTable(table)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var setParts []string
|
|
var vals []any
|
|
idx := 1
|
|
|
|
for _, item := range patch.Items() {
|
|
col, ok := item[0].(starlark.String)
|
|
if !ok {
|
|
return nil, fmt.Errorf("db.update: column key must be a string")
|
|
}
|
|
colStr := string(col)
|
|
if colStr == "id" || colStr == "created_at" {
|
|
continue
|
|
}
|
|
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
|
return nil, fmt.Errorf("db.update: invalid column name %q", colStr)
|
|
}
|
|
val, err := starlarkToGoValue(item[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("db.update: column %q: %w", colStr, err)
|
|
}
|
|
setParts = append(setParts, fmt.Sprintf("%s = %s", colStr, cfg.ph(idx)))
|
|
vals = append(vals, val)
|
|
idx++
|
|
}
|
|
|
|
if len(setParts) == 0 {
|
|
return starlark.True, nil
|
|
}
|
|
|
|
vals = append(vals, rowID)
|
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE id = %s",
|
|
physTable,
|
|
strings.Join(setParts, ", "),
|
|
cfg.ph(idx),
|
|
)
|
|
|
|
if _, err := cfg.DB.ExecContext(ctx, query, vals...); err != nil {
|
|
return nil, fmt.Errorf("db.update: %w", err)
|
|
}
|
|
return starlark.True, nil
|
|
}
|
|
}
|
|
|
|
// dbDelete implements db.delete(table, id) → True.
|
|
func dbDelete(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, rowID string
|
|
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &table, &rowID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
physTable, err := cfg.physicalTable(table)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query := fmt.Sprintf("DELETE FROM %s WHERE id = %s", physTable, cfg.ph(1))
|
|
if _, err := cfg.DB.ExecContext(ctx, query, rowID); err != nil {
|
|
return nil, fmt.Errorf("db.delete: %w", err)
|
|
}
|
|
return starlark.True, nil
|
|
}
|
|
}
|