zoobzio January 3, 2026 Edit this page

LLM Integration

Edamame's typed statements with self-describing metadata make it ideal for AI-assisted database operations.

The Pattern

  1. Define statements with descriptive names and descriptions
  2. Collect statement metadata into a registry
  3. Serialize registry as JSON for LLM context
  4. LLM selects statement by name and provides params
  5. Execute statement with LLM-provided inputs
User Query → LLM (with statement metadata) → Statement Name + Params → Edamame → Results

Defining Statements

Statements are self-describing with name, description, params, and tags:

var (
    QueryAll = edamame.NewQueryStatement("query-all", "Query all users", edamame.QuerySpec{})

    ByRole = edamame.NewQueryStatement("by-role", "Find users by role", edamame.QuerySpec{
        Where: []edamame.ConditionSpec{
            {Field: "role", Operator: "=", Param: "role"},
        },
    }, "filter", "security")

    ActiveAdults = edamame.NewQueryStatement("active-adults", "Find active users over 18", edamame.QuerySpec{
        Where: []edamame.ConditionSpec{
            {Field: "active", Operator: "=", Param: "active"},
            {Field: "age", Operator: ">=", Param: "min_age"},
        },
    }, "filter")

    SelectByID = edamame.NewSelectStatement("select-by-id", "Select a single user by ID", edamame.SelectSpec{
        Where: []edamame.ConditionSpec{
            {Field: "id", Operator: "=", Param: "id"},
        },
    })

    CountAll = edamame.NewAggregateStatement("count-all", "Count all users", edamame.AggCount, edamame.AggregateSpec{})
)

Building a Statement Registry

Create a registry to collect statements for LLM consumption:

type StatementInfo struct {
    Name        string      `json:"name"`
    Description string      `json:"description"`
    Type        string      `json:"type"`
    Params      []ParamInfo `json:"params"`
    Tags        []string    `json:"tags,omitempty"`
}

type ParamInfo struct {
    Name     string `json:"name"`
    Type     string `json:"type"`
    Required bool   `json:"required"`
}

type StatementRegistry struct {
    Table      string          `json:"table"`
    Queries    []StatementInfo `json:"queries,omitempty"`
    Selects    []StatementInfo `json:"selects,omitempty"`
    Updates    []StatementInfo `json:"updates,omitempty"`
    Deletes    []StatementInfo `json:"deletes,omitempty"`
    Aggregates []StatementInfo `json:"aggregates,omitempty"`
}

func NewRegistry(table string) *StatementRegistry {
    return &StatementRegistry{Table: table}
}

func (r *StatementRegistry) AddQuery(stmt edamame.QueryStatement) {
    r.Queries = append(r.Queries, toStatementInfo(stmt.Name(), stmt.Description(), "query", stmt.Params(), stmt.Tags()))
}

func (r *StatementRegistry) AddSelect(stmt edamame.SelectStatement) {
    r.Selects = append(r.Selects, toStatementInfo(stmt.Name(), stmt.Description(), "select", stmt.Params(), stmt.Tags()))
}

func (r *StatementRegistry) AddAggregate(stmt edamame.AggregateStatement) {
    r.Aggregates = append(r.Aggregates, toStatementInfo(stmt.Name(), stmt.Description(), "aggregate", stmt.Params(), stmt.Tags()))
}

func toStatementInfo(name, desc, typ string, params []edamame.ParamSpec, tags []string) StatementInfo {
    info := StatementInfo{
        Name:        name,
        Description: desc,
        Type:        typ,
        Tags:        tags,
    }
    for _, p := range params {
        info.Params = append(info.Params, ParamInfo{
            Name:     p.Name,
            Type:     p.Type,
            Required: p.Required,
        })
    }
    return info
}

func (r *StatementRegistry) JSON() (string, error) {
    data, err := json.MarshalIndent(r, "", "  ")
    return string(data), err
}

Exporting for LLM Context

// Build registry
registry := NewRegistry("users")
registry.AddQuery(QueryAll)
registry.AddQuery(ByRole)
registry.AddQuery(ActiveAdults)
registry.AddSelect(SelectByID)
registry.AddAggregate(CountAll)

// Export as JSON
json, _ := registry.JSON()

Example output:

{
  "table": "users",
  "queries": [
    {
      "name": "query-all",
      "description": "Query all users",
      "type": "query",
      "params": []
    },
    {
      "name": "by-role",
      "description": "Find users by role",
      "type": "query",
      "params": [
        {"name": "role", "type": "any", "required": true}
      ],
      "tags": ["filter", "security"]
    },
    {
      "name": "active-adults",
      "description": "Find active users over 18",
      "type": "query",
      "params": [
        {"name": "active", "type": "any", "required": true},
        {"name": "min_age", "type": "any", "required": true}
      ],
      "tags": ["filter"]
    }
  ],
  "selects": [
    {
      "name": "select-by-id",
      "description": "Select a single user by ID",
      "type": "select",
      "params": [
        {"name": "id", "type": "any", "required": true}
      ]
    }
  ],
  "aggregates": [
    {
      "name": "count-all",
      "description": "Count all users",
      "type": "aggregate",
      "params": []
    }
  ]
}

System Prompt Design

Provide statement metadata in your LLM system prompt:

You are a database assistant. You have access to the following operations:

{registry_json}

When the user asks a question about data:
1. Identify the appropriate operation
2. Extract required parameters from the user's request
3. Respond with JSON: {"statement": "name", "type": "query|select|...", "params": {...}}

Examples:
- "How many users are there?" → {"statement": "count-all", "type": "aggregate", "params": {}}
- "Find admins" → {"statement": "by-role", "type": "query", "params": {"role": "admin"}}
- "Get user 123" → {"statement": "select-by-id", "type": "select", "params": {"id": 123}}

Executing LLM Responses

Create a dispatcher that maps statement names to actual statements:

type LLMResponse struct {
    Statement string         `json:"statement"`
    Type      string         `json:"type"`
    Params    map[string]any `json:"params"`
}

type StatementDispatcher struct {
    exec       *edamame.Executor[User]
    queries    map[string]edamame.QueryStatement
    selects    map[string]edamame.SelectStatement
    aggregates map[string]edamame.AggregateStatement
}

func NewDispatcher(exec *edamame.Executor[User]) *StatementDispatcher {
    return &StatementDispatcher{
        exec:       exec,
        queries:    make(map[string]edamame.QueryStatement),
        selects:    make(map[string]edamame.SelectStatement),
        aggregates: make(map[string]edamame.AggregateStatement),
    }
}

func (d *StatementDispatcher) RegisterQuery(stmt edamame.QueryStatement) {
    d.queries[stmt.Name()] = stmt
}

func (d *StatementDispatcher) RegisterSelect(stmt edamame.SelectStatement) {
    d.selects[stmt.Name()] = stmt
}

func (d *StatementDispatcher) RegisterAggregate(stmt edamame.AggregateStatement) {
    d.aggregates[stmt.Name()] = stmt
}

func (d *StatementDispatcher) Execute(ctx context.Context, resp LLMResponse) (any, error) {
    switch resp.Type {
    case "query":
        stmt, ok := d.queries[resp.Statement]
        if !ok {
            return nil, fmt.Errorf("unknown query: %s", resp.Statement)
        }
        return d.exec.ExecQuery(ctx, stmt, resp.Params)
    case "select":
        stmt, ok := d.selects[resp.Statement]
        if !ok {
            return nil, fmt.Errorf("unknown select: %s", resp.Statement)
        }
        return d.exec.ExecSelect(ctx, stmt, resp.Params)
    case "aggregate":
        stmt, ok := d.aggregates[resp.Statement]
        if !ok {
            return nil, fmt.Errorf("unknown aggregate: %s", resp.Statement)
        }
        return d.exec.ExecAggregate(ctx, stmt, resp.Params)
    default:
        return nil, fmt.Errorf("unknown type: %s", resp.Type)
    }
}

Complete Setup

// Create executor
exec, _ := edamame.New[User](db, "users", postgres.New())

// Create dispatcher and register statements
dispatcher := NewDispatcher(exec)
dispatcher.RegisterQuery(QueryAll)
dispatcher.RegisterQuery(ByRole)
dispatcher.RegisterQuery(ActiveAdults)
dispatcher.RegisterSelect(SelectByID)
dispatcher.RegisterAggregate(CountAll)

// Build registry for LLM context
registry := NewRegistry("users")
registry.AddQuery(QueryAll)
registry.AddQuery(ByRole)
registry.AddQuery(ActiveAdults)
registry.AddSelect(SelectByID)
registry.AddAggregate(CountAll)

llmContext, _ := registry.JSON()

Validation

Validate LLM responses before execution:

func (d *StatementDispatcher) Validate(resp LLMResponse) error {
    switch resp.Type {
    case "query":
        stmt, ok := d.queries[resp.Statement]
        if !ok {
            return fmt.Errorf("unknown query: %s", resp.Statement)
        }
        return validateParams(stmt.Params(), resp.Params)
    case "select":
        stmt, ok := d.selects[resp.Statement]
        if !ok {
            return fmt.Errorf("unknown select: %s", resp.Statement)
        }
        return validateParams(stmt.Params(), resp.Params)
    case "aggregate":
        stmt, ok := d.aggregates[resp.Statement]
        if !ok {
            return fmt.Errorf("unknown aggregate: %s", resp.Statement)
        }
        return validateParams(stmt.Params(), resp.Params)
    default:
        return fmt.Errorf("unknown type: %s", resp.Type)
    }
}

func validateParams(specs []edamame.ParamSpec, provided map[string]any) error {
    for _, spec := range specs {
        if spec.Required {
            if _, ok := provided[spec.Name]; !ok {
                return fmt.Errorf("missing required param: %s", spec.Name)
            }
        }
    }
    return nil
}

Rate Limiting

Protect against LLM-driven query floods:

type RateLimitedDispatcher struct {
    dispatcher *StatementDispatcher
    limiter    *rate.Limiter
}

func NewRateLimitedDispatcher(dispatcher *StatementDispatcher, rps float64) *RateLimitedDispatcher {
    return &RateLimitedDispatcher{
        dispatcher: dispatcher,
        limiter:    rate.NewLimiter(rate.Limit(rps), 10),
    }
}

func (d *RateLimitedDispatcher) Execute(ctx context.Context, resp LLMResponse) (any, error) {
    if err := d.limiter.Wait(ctx); err != nil {
        return nil, err
    }
    return d.dispatcher.Execute(ctx, resp)
}

Audit Logging

Log LLM-driven operations:

func (d *StatementDispatcher) ExecuteWithAudit(ctx context.Context, resp LLMResponse, userID string) (any, error) {
    start := time.Now()

    result, err := d.Execute(ctx, resp)

    log.Printf("LLM execution: user=%s statement=%s type=%s params=%v duration=%v error=%v",
        userID,
        resp.Statement,
        resp.Type,
        resp.Params,
        time.Since(start),
        err,
    )

    return result, err
}

Example: Chat Interface

Complete example with a simple chat interface:

func HandleChat(ctx context.Context, dispatcher *StatementDispatcher, registry *StatementRegistry, llm LLMClient, userMessage string) string {
    // 1. Get statement metadata
    specs, _ := registry.JSON()

    // 2. Build prompt
    prompt := fmt.Sprintf(`You are a database assistant. Available operations:
%s

User: %s

Respond with JSON: {"statement": "...", "type": "...", "params": {...}}
Or respond with {"error": "..."} if the request cannot be fulfilled.`, specs, userMessage)

    // 3. Get LLM response
    llmResp, err := llm.Complete(ctx, prompt)
    if err != nil {
        return "I couldn't process that request."
    }

    // 4. Parse response
    var resp LLMResponse
    if err := json.Unmarshal([]byte(llmResp), &resp); err != nil {
        return "I couldn't understand how to query the database."
    }

    // 5. Validate
    if err := dispatcher.Validate(resp); err != nil {
        return fmt.Sprintf("Invalid request: %v", err)
    }

    // 6. Execute
    result, err := dispatcher.Execute(ctx, resp)
    if err != nil {
        return fmt.Sprintf("Query failed: %v", err)
    }

    // 7. Format response
    return formatResult(result)
}

Security Considerations

  • Validate all LLM outputs before execution
  • Use parameterized queries (edamame handles this)
  • Limit exposed statements to what's safe
  • Rate limit LLM-driven operations
  • Audit log all executions
  • Never expose raw SQL generation to LLMs