LLM Integration
Edamame's typed statements with self-describing metadata make it ideal for AI-assisted database operations.
The Pattern
- Define statements with descriptive names and descriptions
- Collect statement metadata into a registry
- Serialize registry as JSON for LLM context
- LLM selects statement by name and provides params
- 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