mirror of
https://github.com/go-micro/go-micro.git
synced 2026-06-15 19:35:13 +02:00
Claude/update docs roadmap f zd2 j (#2880)
* docs: update all four documentation guides and mark Q2 complete - ai-native-services: add WithMCP one-liner, standalone gateway, WebSocket client example, and OpenTelemetry observability section - mcp-security: add OTel distributed tracing, WebSocket authentication (connection-level and per-message), DeniedReason audit field - tool-descriptions: add manual overrides with WithEndpointDocs and export formats section - agent-patterns: add LangChain/LlamaIndex SDK pattern and standalone gateway production pattern with Docker example - Update roadmap: mark Q2 documentation as complete, Q2 at 100% - Update status: reflect all recent completions, shift priorities https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: add agent demo example and blog post Add examples/agent-demo with a multi-service project management app (projects, tasks, team) that demonstrates AI agents interacting with Go Micro services through MCP. Includes seed data and example prompts. Add blog post 4 "Agents Meet Microservices: A Hands-On Demo" walking through the example code and showing cross-service agent workflows. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: enable multiple services in a single binary Remove global state mutations from service and cmd option functions so that configuring one service no longer overwrites another's settings. Key changes: - service/options.go: remove all DefaultXxx global writes from option functions; newOptions() now creates fresh Server, Client, Store, and Cache per service while sharing Registry, Broker, and Transport - cmd/cmd.go: newCmd() uses local copies instead of pointers to package globals; Before() no longer mutates DefaultXxx vars - cmd/options.go: remove global mutations from all option functions - service/service.go: export ServiceImpl type for cross-package use - service/group.go: new Group type for multi-service lifecycle - micro.go: add Start/Stop to Service interface, expose Group and NewGroup convenience function - examples/multi-service: working example with two services https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * docs: highlight multi-service binary support Add multi-service section to README with code example, update features list, add to examples index, and note in status summary. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: unify service API and clean up developer experience - Unified service creation: micro.New("name", opts...) as canonical API - Clean handler registration: service.Handle(handler, opts...) accepts server.HandlerOption args directly, no need to reach through Server() - Unexported serviceImpl: users interact through Service interface only - Service groups use Service interface (not concrete type) - Fixed Stop() to properly propagate BeforeStop/AfterStop errors - Fixed store init: error-level log instead of fatal on init failure - Updated all examples to use consistent patterns - Updated README, getting-started, MCP docs, and guides - Added blog post about the DX cleanup https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * fix: add blog post 5 to blog index Blog post 5 (Developer Experience Cleanup) existed as a file but was missing from the blog index page. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: make micro new generate MCP-enabled services by default - main.go template includes mcp.WithMCP(":3001") by default - Handler template has agent-friendly doc comments with @example tags - Proto template has descriptive field comments - README includes MCP usage, Claude Code config, and tool description tips - Makefile adds mcp-tools, mcp-test, mcp-serve targets - go.mod updated to Go 1.22 - Added --no-mcp flag to opt out of MCP integration - Post-create output shows MCP endpoint URLs https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * docs: add MCP migration guide and troubleshooting guide - Migration guide: 3 approaches to add MCP to existing services (WithMCP one-liner, standalone gateway, CLI) - Troubleshooting guide: common issues with agents, WebSocket, Claude Code, auth, rate limiting, and performance https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * refactor: rename model/ package to ai/ for AI model providers The model/ package name conflicted with the conventional use of "model" for data models. Renamed to ai/ which better describes the package's purpose (AI provider abstraction for Anthropic, OpenAI, etc.) and frees up model/ for future data model layer use. - Rename model/ → ai/ with package name change - Update all Go imports from go-micro.dev/v5/model to go-micro.dev/v5/ai - Update cmd/micro/server/server.go references (model.X → ai.X) - Update all documentation and roadmap references - All tests pass, CLI builds successfully https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: add model package for typed data access with CRUD and queries New model/ package provides a typed data model layer using Go generics. Supports structured CRUD operations, WHERE filters, ordering, pagination, and automatic schema creation from struct tags. Three backends: - memory: in-memory for development and testing - sqlite: embedded SQL for dev and single-node production - postgres: full PostgreSQL for production deployments Key features: - Generic Model[T] with Create/Read/Update/Delete/List/Count - Query builder: Where(), WhereOp(), OrderAsc/Desc(), Limit(), Offset() - Struct tags: model:"key" for primary key, model:"index" for indexes - Auto table creation from struct schema - 19 tests passing across memory and sqlite backends https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: add model code generation to protoc-gen-micro Extend the micro plugin to generate model structs from proto messages annotated with // @model. Generated alongside client/server code in the same .pb.micro.go file. For a proto message like: // @model message User { string id = 1; string name = 2; } Generates: - UserModel struct with model:"key" and json tags - NewUserModel(db) factory returning *model.Model[UserModel] - UserModelFromProto(*User) *UserModel converter - (*UserModel).ToProto() *User converter Supports @model(table=custom_table, key=custom_field) options. Adds GetComments() to generator for plugin comment inspection. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc * feat: add Model() to Service interface for Client/Server/Model trifecta Every service now exposes Client(), Server(), and Model() — call services, handle requests, and save/query data from the same interface. Includes README docs, blog post, and a full model guide on the docs site. https://claude.ai/code/session_01GkduEhcrqcG45rdfYh8dAc --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ make test
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./gateway/mcp/...
|
||||
go test ./ai/...
|
||||
go test ./model/...
|
||||
|
||||
# Lint
|
||||
@@ -48,9 +49,13 @@ go-micro/
|
||||
├── health/ # Health checking
|
||||
├── logger/ # Logging
|
||||
├── metadata/ # Context metadata
|
||||
├── model/ # AI model providers
|
||||
├── ai/ # AI model providers
|
||||
│ ├── anthropic/ # Claude provider
|
||||
│ └── openai/ # GPT provider
|
||||
├── model/ # Typed data models (CRUD, queries, schemas)
|
||||
│ ├── memory/ # In-memory backend (dev/testing)
|
||||
│ ├── sqlite/ # SQLite backend (dev/single-node)
|
||||
│ └── postgres/ # PostgreSQL backend (production)
|
||||
├── registry/ # Service discovery (mDNS, Consul, etcd)
|
||||
├── selector/ # Client-side load balancing
|
||||
├── server/ # RPC server
|
||||
@@ -118,7 +123,8 @@ Build compelling demos showing agents interacting with go-micro services in real
|
||||
|---------|------|
|
||||
| MCP Gateway | `gateway/mcp/mcp.go` |
|
||||
| MCP Docs | `gateway/mcp/DOCUMENTATION.md` |
|
||||
| Model Interface | `model/model.go` |
|
||||
| AI Interface | `ai/model.go` |
|
||||
| Model Layer | `model/model.go` |
|
||||
| CLI Entry | `cmd/micro/main.go` |
|
||||
| MCP CLI | `cmd/micro/mcp/` |
|
||||
| Server (run/server) | `cmd/micro/server/server.go` |
|
||||
|
||||
@@ -24,6 +24,10 @@ Go Micro abstracts away the details of distributed systems. Here are the main fe
|
||||
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
|
||||
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
|
||||
|
||||
- **Data Model** - A typed data model layer with CRUD operations, queries, and multiple backends (memory, SQLite, Postgres). Define Go
|
||||
structs with tags and get type-safe Create/Read/Update/Delete/List/Count operations. Accessible via `service.Model()` alongside
|
||||
`service.Client()` and `service.Server()` for a complete service experience: call services, handle requests, save and query data.
|
||||
|
||||
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
|
||||
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
|
||||
multicast DNS (mdns), a zeroconf system.
|
||||
@@ -165,6 +169,74 @@ Each service gets its own server, client, store, and cache while sharing the reg
|
||||
|
||||
See the [multi-service example](examples/multi-service/) for a working demo.
|
||||
|
||||
## Data Model
|
||||
|
||||
Go Micro includes a typed data model layer for persistence. Define a struct, tag a key field, and get type-safe CRUD and query operations backed by memory, SQLite, or Postgres.
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/model/sqlite"
|
||||
)
|
||||
|
||||
// Define your data type
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" model:"index"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
```
|
||||
|
||||
Create a model from the service's database and use it:
|
||||
|
||||
```go
|
||||
service := micro.New("users")
|
||||
|
||||
// Create a typed model using the service's database
|
||||
users := model.New[User](service.Model())
|
||||
|
||||
// CRUD operations
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
|
||||
|
||||
user, _ := users.Read(ctx, "1")
|
||||
|
||||
user.Name = "Alice Smith"
|
||||
users.Update(ctx, user)
|
||||
|
||||
users.Delete(ctx, "1")
|
||||
```
|
||||
|
||||
Query with filters, ordering, and pagination:
|
||||
|
||||
```go
|
||||
// Find users by field
|
||||
results, _ := users.List(ctx, model.Where("email", "alice@example.com"))
|
||||
|
||||
// Complex queries
|
||||
results, _ = users.List(ctx,
|
||||
model.WhereOp("age", ">=", 18),
|
||||
model.OrderDesc("name"),
|
||||
model.Limit(10),
|
||||
model.Offset(20),
|
||||
)
|
||||
|
||||
count, _ := users.Count(ctx, model.Where("age", 30))
|
||||
```
|
||||
|
||||
Swap backends with an option:
|
||||
|
||||
```go
|
||||
// Development: in-memory (default)
|
||||
service := micro.New("users")
|
||||
|
||||
// Production: SQLite or Postgres
|
||||
db, _ := sqlite.New(model.WithDSN("file:app.db"))
|
||||
service := micro.New("users", micro.Model(db))
|
||||
```
|
||||
|
||||
Every service gets `Client()`, `Server()`, and `Model()` — call services, handle requests, and save data all from the same interface.
|
||||
|
||||
## Examples
|
||||
|
||||
Check out [/examples](examples/) for runnable code:
|
||||
@@ -296,6 +368,7 @@ Package reference: https://pkg.go.dev/go-micro.dev/v5
|
||||
|
||||
**User Guides:**
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [Data Model](internal/website/docs/model.md)
|
||||
- [MCP & AI Agents](internal/website/docs/mcp.md)
|
||||
- [Plugins Overview](internal/website/docs/plugins.md)
|
||||
- [Learn by Example](internal/website/docs/examples/index.md)
|
||||
|
||||
+4
-4
@@ -17,11 +17,11 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
- [ ] Plugin discovery dashboard
|
||||
|
||||
### AI & Model Integration
|
||||
- [x] Model package with provider abstraction (`model.Model` interface)
|
||||
- [x] Anthropic Claude provider (`model/anthropic`)
|
||||
- [x] OpenAI GPT provider (`model/openai`)
|
||||
- [x] AI package with provider abstraction (`ai.Model` interface)
|
||||
- [x] Anthropic Claude provider (`ai/anthropic`)
|
||||
- [x] OpenAI GPT provider (`ai/openai`)
|
||||
- [x] Tool execution with auto-calling support
|
||||
- [x] Streaming support via `model.Stream`
|
||||
- [x] Streaming support via `ai.Stream`
|
||||
|
||||
### Observability
|
||||
- [ ] OpenTelemetry native support
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
# AI Package
|
||||
|
||||
The `ai` package provides a simple, high-level interface for AI model providers like Anthropic Claude and OpenAI GPT.
|
||||
|
||||
## Interface
|
||||
|
||||
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
|
||||
|
||||
```go
|
||||
type Model interface {
|
||||
Init(...Option) error
|
||||
Options() Options
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
)
|
||||
|
||||
// Create a model
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("your-api-key"),
|
||||
ai.WithModel("gpt-4o"),
|
||||
)
|
||||
|
||||
// Generate a response
|
||||
req := &ai.Request{
|
||||
Prompt: "What is Go?",
|
||||
SystemPrompt: "You are a helpful programming assistant",
|
||||
}
|
||||
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Reply)
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Configure the model using functional options:
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey("your-key"), // Required
|
||||
ai.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
|
||||
ai.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
|
||||
)
|
||||
```
|
||||
|
||||
You can also update options after creation:
|
||||
|
||||
```go
|
||||
m.Init(
|
||||
ai.WithModel("gpt-4o-mini"),
|
||||
ai.WithAPIKey("new-key"),
|
||||
)
|
||||
```
|
||||
|
||||
## Using Tools
|
||||
|
||||
The model can automatically execute tool calls when provided with a tool handler:
|
||||
|
||||
```go
|
||||
// Define a tool handler
|
||||
toolHandler := func(name string, input map[string]any) (result any, content string) {
|
||||
// Execute the tool and return results
|
||||
switch name {
|
||||
case "get_weather":
|
||||
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
|
||||
default:
|
||||
return nil, `{"error": "unknown tool"}`
|
||||
}
|
||||
}
|
||||
|
||||
// Create model with tool handler
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithToolHandler(toolHandler),
|
||||
)
|
||||
|
||||
// Provide tools in the request
|
||||
req := &ai.Request{
|
||||
Prompt: "What's the weather?",
|
||||
SystemPrompt: "You are a helpful assistant",
|
||||
Tools: []ai.Tool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Properties: map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate will automatically call tools and return final answer
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
fmt.Println(resp.Answer) // Final answer after tool execution
|
||||
```
|
||||
|
||||
## Response Structure
|
||||
|
||||
```go
|
||||
type Response struct {
|
||||
Reply string // Initial reply from model
|
||||
ToolCalls []ToolCall // Tools the model wants to call
|
||||
Answer string // Final answer (after tool execution if handler provided)
|
||||
}
|
||||
```
|
||||
|
||||
- `Reply`: The model's first response
|
||||
- `ToolCalls`: List of tools the model requested (if any)
|
||||
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey("sk-ant-..."),
|
||||
ai.WithModel("claude-sonnet-4-20250514"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `claude-sonnet-4-20250514`
|
||||
Default base URL: `https://api.anthropic.com`
|
||||
|
||||
### OpenAI GPT
|
||||
|
||||
```go
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("sk-..."),
|
||||
ai.WithModel("gpt-4o"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `gpt-4o`
|
||||
Default base URL: `https://api.openai.com`
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
Use `AutoDetectProvider()` to detect the provider from a base URL:
|
||||
|
||||
```go
|
||||
provider := ai.AutoDetectProvider("https://api.anthropic.com")
|
||||
// Returns "anthropic"
|
||||
|
||||
m := ai.New(provider, ai.WithAPIKey("..."))
|
||||
```
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
1. Create a new package under `ai/`:
|
||||
|
||||
```go
|
||||
package myprovider
|
||||
|
||||
import "go-micro.dev/v5/ai"
|
||||
|
||||
func init() {
|
||||
ai.Register("myprovider", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
// Set defaults
|
||||
if options.Model == "" {
|
||||
options.Model = "my-default-model"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.myprovider.com"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
func (p *Provider) String() string {
|
||||
return "myprovider"
|
||||
}
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Implement your provider logic
|
||||
// - Build API request
|
||||
// - Make HTTP call
|
||||
// - Parse response
|
||||
// - Handle tools if ToolHandler is set
|
||||
return &ai.Response{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
2. Import your provider:
|
||||
|
||||
```go
|
||||
import _ "go-micro.dev/v5/ai/myprovider"
|
||||
```
|
||||
|
||||
## Comparison with Other Packages
|
||||
|
||||
The ai package follows the same patterns as other go-micro packages:
|
||||
|
||||
**Registry:**
|
||||
```go
|
||||
r := registry.NewRegistry(registry.Addrs("..."))
|
||||
r.Register(service)
|
||||
```
|
||||
|
||||
**Client:**
|
||||
```go
|
||||
c := client.NewClient(client.Retries(3))
|
||||
c.Call(ctx, req, rsp)
|
||||
```
|
||||
|
||||
**AI:**
|
||||
```go
|
||||
m := ai.New("openai", ai.WithAPIKey("..."))
|
||||
m.Generate(ctx, req)
|
||||
```
|
||||
|
||||
All use:
|
||||
- `Init()` to update options
|
||||
- `Options()` to get current options
|
||||
- `String()` to get the implementation name
|
||||
- Functional options pattern
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./ai/...
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the [server implementation](../cmd/micro/server/server.go) for a complete example of using the ai package with tool execution.
|
||||
@@ -10,24 +10,24 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
model.Register("anthropic", func(opts ...model.Option) model.Model {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the model.Model interface for Anthropic Claude
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
type Provider struct {
|
||||
opts model.Options
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Anthropic provider
|
||||
func NewProvider(opts ...model.Option) *Provider {
|
||||
options := model.NewOptions(opts...)
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "claude-sonnet-4-20250514"
|
||||
@@ -35,14 +35,14 @@ func NewProvider(opts ...model.Option) *Provider {
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.anthropic.com"
|
||||
}
|
||||
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...model.Option) error {
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (p *Provider) Init(opts ...model.Option) error {
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() model.Options {
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (p *Provider) String() string {
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Build tools for Anthropic format
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
@@ -101,10 +101,10 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
|
||||
|
||||
// If tool handler is provided, execute tools and get final answer
|
||||
if p.opts.ToolHandler != nil {
|
||||
var toolResults []model.ToolResult
|
||||
var toolResults []ai.ToolResult
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
toolResults = append(toolResults, model.ToolResult{
|
||||
toolResults = append(toolResults, ai.ToolResult{
|
||||
ID: tc.ID,
|
||||
Content: content,
|
||||
})
|
||||
@@ -142,12 +142,12 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Response, any, error) {
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -195,7 +195,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &model.Response{}
|
||||
response := &ai.Response{}
|
||||
|
||||
// Extract text reply
|
||||
var replyParts []string
|
||||
@@ -215,7 +215,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, model.ToolCall{
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Name: block.Name,
|
||||
Input: input,
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
@@ -16,17 +16,17 @@ func TestProvider_String(t *testing.T) {
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
|
||||
err := p.Init(
|
||||
model.WithModel("test-model"),
|
||||
model.WithAPIKey("test-key"),
|
||||
model.WithBaseURL("https://test.com"),
|
||||
ai.WithModel("test-model"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "test-model" {
|
||||
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
|
||||
@@ -41,10 +41,10 @@ func TestProvider_Init(t *testing.T) {
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
model.WithModel("custom-model"),
|
||||
model.WithAPIKey("my-key"),
|
||||
ai.WithModel("custom-model"),
|
||||
ai.WithAPIKey("my-key"),
|
||||
)
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "custom-model" {
|
||||
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
|
||||
@@ -56,7 +56,7 @@ func TestProvider_Options(t *testing.T) {
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("Expected default model 'claude-sonnet-4-20250514', got '%s'", opts.Model)
|
||||
@@ -68,12 +68,12 @@ func TestProvider_Defaults(t *testing.T) {
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &model.Request{
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
SystemPrompt: "You are helpful",
|
||||
}
|
||||
|
||||
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
@@ -82,11 +82,11 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &model.Request{
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Package ai provides abstraction for AI model providers
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model provides an interface for interacting with AI model providers
|
||||
type Model interface {
|
||||
// Init initializes the model with options
|
||||
Init(...Option) error
|
||||
// Options returns the model options
|
||||
Options() Options
|
||||
// Generate generates a response from the model
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
// Stream generates a streaming response (for future implementation)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
// String returns the name of the provider
|
||||
String() string
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that can be called by the model
|
||||
type Tool struct {
|
||||
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
|
||||
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
|
||||
Description string
|
||||
Properties map[string]any // JSON schema for tool parameters
|
||||
}
|
||||
|
||||
// Request represents a request to generate content from a model
|
||||
type Request struct {
|
||||
// Prompt is the user's message/prompt
|
||||
Prompt string
|
||||
// SystemPrompt is the system instruction for the model
|
||||
SystemPrompt string
|
||||
// Tools available for the model to use
|
||||
Tools []Tool
|
||||
// Messages for continuing a conversation (optional)
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
// Message represents a conversation message
|
||||
type Message struct {
|
||||
Role string // "user", "assistant", "system", "tool"
|
||||
Content any // Can be string or structured content
|
||||
}
|
||||
|
||||
// Response represents the response from a model
|
||||
type Response struct {
|
||||
// Reply is the text response from the model
|
||||
Reply string
|
||||
// ToolCalls are tool calls requested by the model
|
||||
ToolCalls []ToolCall
|
||||
// Answer is the final answer after tool execution (if tools were used)
|
||||
Answer string
|
||||
}
|
||||
|
||||
// ToolCall represents a request to call a tool
|
||||
type ToolCall struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Name string // Tool name
|
||||
Input map[string]any // Tool input arguments
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Content string // Tool execution result (JSON string)
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
// Close closes the stream
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles tool calls
|
||||
type ToolHandler func(name string, input map[string]any) (result any, content string)
|
||||
|
||||
// NewFunc creates a new Model instance
|
||||
type NewFunc func(...Option) Model
|
||||
|
||||
var providers = make(map[string]NewFunc)
|
||||
|
||||
// Register registers a model provider
|
||||
func Register(name string, fn NewFunc) {
|
||||
providers[name] = fn
|
||||
}
|
||||
|
||||
// New creates a new Model instance based on the provider name
|
||||
func New(provider string, opts ...Option) Model {
|
||||
if fn, ok := providers[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
|
||||
// Default to first registered provider
|
||||
if len(providers) > 0 {
|
||||
for _, fn := range providers {
|
||||
return fn(opts...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoDetectProvider attempts to detect the provider from the base URL
|
||||
func AutoDetectProvider(baseURL string) string {
|
||||
if baseURL == "" {
|
||||
return "openai"
|
||||
}
|
||||
// Simple detection based on URL
|
||||
if strings.Contains(baseURL, "anthropic") {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// DefaultModel is a default model instance
|
||||
var DefaultModel Model
|
||||
|
||||
// Generate generates a response using the default model
|
||||
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
if DefaultModel == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return DefaultModel.Generate(ctx, req, opts...)
|
||||
}
|
||||
@@ -10,24 +10,24 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
model.Register("openai", func(opts ...model.Option) model.Model {
|
||||
ai.Register("openai", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the model.Model interface for OpenAI
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
type Provider struct {
|
||||
opts model.Options
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new OpenAI provider
|
||||
func NewProvider(opts ...model.Option) *Provider {
|
||||
options := model.NewOptions(opts...)
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "gpt-4o"
|
||||
@@ -35,14 +35,14 @@ func NewProvider(opts ...model.Option) *Provider {
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.openai.com"
|
||||
}
|
||||
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...model.Option) error {
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (p *Provider) Init(opts ...model.Option) error {
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() model.Options {
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (p *Provider) String() string {
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Build tools for OpenAI format
|
||||
var openaiTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
@@ -138,12 +138,12 @@ func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...mod
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Response, map[string]any, error) {
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -199,7 +199,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &model.Response{
|
||||
response := &ai.Response{
|
||||
Reply: choice.Message.Content,
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*model.Resp
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, model.ToolCall{
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
@@ -16,17 +16,17 @@ func TestProvider_String(t *testing.T) {
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
|
||||
err := p.Init(
|
||||
model.WithModel("test-model"),
|
||||
model.WithAPIKey("test-key"),
|
||||
model.WithBaseURL("https://test.com"),
|
||||
ai.WithModel("test-model"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "test-model" {
|
||||
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
|
||||
@@ -41,10 +41,10 @@ func TestProvider_Init(t *testing.T) {
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
model.WithModel("custom-model"),
|
||||
model.WithAPIKey("my-key"),
|
||||
ai.WithModel("custom-model"),
|
||||
ai.WithAPIKey("my-key"),
|
||||
)
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "custom-model" {
|
||||
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
|
||||
@@ -56,7 +56,7 @@ func TestProvider_Options(t *testing.T) {
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "gpt-4o" {
|
||||
t.Errorf("Expected default model 'gpt-4o', got '%s'", opts.Model)
|
||||
@@ -68,12 +68,12 @@ func TestProvider_Defaults(t *testing.T) {
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &model.Request{
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
SystemPrompt: "You are helpful",
|
||||
}
|
||||
|
||||
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
@@ -82,11 +82,11 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &model.Request{
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
@@ -0,0 +1,77 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Options for model configuration
|
||||
type Options struct {
|
||||
// Context for the model
|
||||
Context context.Context
|
||||
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
|
||||
Model string
|
||||
// APIKey for authentication
|
||||
APIKey string
|
||||
// BaseURL for the API endpoint
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
type GenerateOptions struct {
|
||||
// Context for this specific generate call
|
||||
Context context.Context
|
||||
}
|
||||
|
||||
// Option is a function that modifies Options
|
||||
type Option func(*Options)
|
||||
|
||||
// GenerateOption is a function that modifies GenerateOptions
|
||||
type GenerateOption func(*GenerateOptions)
|
||||
|
||||
// NewOptions creates new Options with defaults
|
||||
func NewOptions(opts ...Option) Options {
|
||||
options := Options{
|
||||
Context: context.Background(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// WithModel sets the model name
|
||||
func WithModel(m string) Option {
|
||||
return func(o *Options) {
|
||||
o.Model = m
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key
|
||||
func WithAPIKey(key string) Option {
|
||||
return func(o *Options) {
|
||||
o.APIKey = key
|
||||
}
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *Options) {
|
||||
o.BaseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolHandler sets the tool handler
|
||||
func WithToolHandler(handler ToolHandler) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolHandler = handler
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -28,9 +28,9 @@ import (
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/model"
|
||||
_ "go-micro.dev/v5/model/anthropic"
|
||||
_ "go-micro.dev/v5/model/openai"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -633,12 +633,12 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
|
||||
// Auto-detect provider if not explicitly set
|
||||
if provider == "" {
|
||||
provider = model.AutoDetectProvider(baseURL)
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
}
|
||||
|
||||
// Discover tools from registry
|
||||
services, _ := registry.ListServices()
|
||||
var discoveredTools []model.Tool
|
||||
var discoveredTools []ai.Tool
|
||||
// safeNameMap maps LLM-safe names back to original dotted names
|
||||
safeNameMap := map[string]string{}
|
||||
for _, svc := range services {
|
||||
@@ -665,7 +665,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
}
|
||||
}
|
||||
discoveredTools = append(discoveredTools, model.Tool{
|
||||
discoveredTools = append(discoveredTools, ai.Tool{
|
||||
Name: safeName,
|
||||
OriginalName: tName,
|
||||
Description: desc,
|
||||
@@ -753,24 +753,24 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
|
||||
// Create model with options
|
||||
var modelOpts []model.Option
|
||||
modelOpts = append(modelOpts, model.WithAPIKey(apiKey))
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
|
||||
if modelName != "" {
|
||||
modelOpts = append(modelOpts, model.WithModel(modelName))
|
||||
modelOpts = append(modelOpts, ai.WithModel(modelName))
|
||||
}
|
||||
if baseURL != "" {
|
||||
modelOpts = append(modelOpts, model.WithBaseURL(baseURL))
|
||||
modelOpts = append(modelOpts, ai.WithBaseURL(baseURL))
|
||||
}
|
||||
modelOpts = append(modelOpts, model.WithToolHandler(executeToolCall))
|
||||
|
||||
m := model.New(provider, modelOpts...)
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(executeToolCall))
|
||||
|
||||
m := ai.New(provider, modelOpts...)
|
||||
if m == nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to create model provider"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build request
|
||||
modelReq := &model.Request{
|
||||
modelReq := &ai.Request{
|
||||
Prompt: req.Prompt,
|
||||
SystemPrompt: agentSystemPrompt,
|
||||
Tools: discoveredTools,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: user.proto
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
proto "google.golang.org/protobuf/proto"
|
||||
math "math"
|
||||
)
|
||||
|
||||
import (
|
||||
context "context"
|
||||
client "go-micro.dev/v5/client"
|
||||
server "go-micro.dev/v5/server"
|
||||
model "go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ = proto.Marshal
|
||||
var _ = fmt.Errorf
|
||||
var _ = math.Inf
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ context.Context
|
||||
var _ client.Option
|
||||
var _ server.Option
|
||||
var _ model.Database
|
||||
|
||||
// Client API for UserService service
|
||||
|
||||
type UserServiceService interface {
|
||||
Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error)
|
||||
Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error)
|
||||
Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error)
|
||||
}
|
||||
|
||||
type userServiceService struct {
|
||||
c client.Client
|
||||
name string
|
||||
}
|
||||
|
||||
func NewUserServiceService(name string, c client.Client) UserServiceService {
|
||||
return &userServiceService{
|
||||
c: c,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *userServiceService) Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Create", in)
|
||||
out := new(CreateUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceService) Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Get", in)
|
||||
out := new(GetUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceService) Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Delete", in)
|
||||
out := new(DeleteUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Server API for UserService service
|
||||
|
||||
type UserServiceHandler interface {
|
||||
Create(context.Context, *CreateUserRequest, *CreateUserResponse) error
|
||||
Get(context.Context, *GetUserRequest, *GetUserResponse) error
|
||||
Delete(context.Context, *DeleteUserRequest, *DeleteUserResponse) error
|
||||
}
|
||||
|
||||
func RegisterUserServiceHandler(s server.Server, hdlr UserServiceHandler, opts ...server.HandlerOption) error {
|
||||
type userService interface {
|
||||
Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error
|
||||
Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error
|
||||
Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error
|
||||
}
|
||||
type UserService struct {
|
||||
userService
|
||||
}
|
||||
h := &userServiceHandler{hdlr}
|
||||
return s.Handle(s.NewHandler(&UserService{h}, opts...))
|
||||
}
|
||||
|
||||
type userServiceHandler struct {
|
||||
UserServiceHandler
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error {
|
||||
return h.UserServiceHandler.Create(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error {
|
||||
return h.UserServiceHandler.Get(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error {
|
||||
return h.UserServiceHandler.Delete(ctx, in, out)
|
||||
}
|
||||
|
||||
// UserModel is a model struct generated from User.
|
||||
// Use NewUserModel to create a typed model backed by any model.Database.
|
||||
type UserModel struct {
|
||||
Id string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Age int32 `json:"age"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// NewUserModel creates a typed model for User backed by the given database.
|
||||
func NewUserModel(db model.Database) *model.Model[UserModel] {
|
||||
return model.New[UserModel](db, model.WithTable("users"))
|
||||
}
|
||||
|
||||
// UserModelFromProto converts a User proto message to a UserModel.
|
||||
func UserModelFromProto(p *User) *UserModel {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserModel{
|
||||
Id: p.GetId(),
|
||||
Name: p.GetName(),
|
||||
Email: p.GetEmail(),
|
||||
Age: p.GetAge(),
|
||||
Status: p.GetStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToProto converts a UserModel to a User proto message.
|
||||
func (m *UserModel) ToProto() *User {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &User{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Email: m.Email,
|
||||
Age: m.Age,
|
||||
Status: m.Status,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "../user";
|
||||
|
||||
// UserService manages user accounts.
|
||||
service UserService {
|
||||
rpc Create(CreateUserRequest) returns (CreateUserResponse) {}
|
||||
rpc Get(GetUserRequest) returns (GetUserResponse) {}
|
||||
rpc Delete(DeleteUserRequest) returns (DeleteUserResponse) {}
|
||||
}
|
||||
|
||||
// @model
|
||||
message User {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
int32 age = 4;
|
||||
string status = 5;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message DeleteUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteUserResponse {}
|
||||
@@ -1193,6 +1193,15 @@ func (g *Generator) PrintComments(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetComments returns the raw leading comment text for the given path, if any.
|
||||
func (g *Generator) GetComments(path string) (string, bool) {
|
||||
loc, ok := g.file.comments[path]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return loc.GetLeadingComments(), true
|
||||
}
|
||||
|
||||
// makeComments generates the comment string for the field, no "\n" at the end
|
||||
func (g *Generator) makeComments(path string) (string, bool) {
|
||||
loc, ok := g.file.comments[path]
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
contextPkgPath = "context"
|
||||
clientPkgPath = "go-micro.dev/v5/client"
|
||||
serverPkgPath = "go-micro.dev/v5/server"
|
||||
modelPkgPath = "go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,6 +43,7 @@ var (
|
||||
contextPkg string
|
||||
clientPkg string
|
||||
serverPkg string
|
||||
modelPkg string
|
||||
pkgImports map[generator.GoPackageName]bool
|
||||
)
|
||||
|
||||
@@ -51,6 +53,7 @@ func (g *micro) Init(gen *generator.Generator) {
|
||||
contextPkg = generator.RegisterUniquePackageName("context", nil)
|
||||
clientPkg = generator.RegisterUniquePackageName("client", nil)
|
||||
serverPkg = generator.RegisterUniquePackageName("server", nil)
|
||||
modelPkg = generator.RegisterUniquePackageName("model", nil)
|
||||
}
|
||||
|
||||
// Given a type name defined in a .proto, return its object.
|
||||
@@ -70,29 +73,66 @@ func (g *micro) P(args ...interface{}) { g.gen.P(args...) }
|
||||
|
||||
// Generate generates code for the services in the given file.
|
||||
func (g *micro) Generate(file *generator.FileDescriptor) {
|
||||
if len(file.FileDescriptorProto.Service) == 0 {
|
||||
// Check if any messages have @model annotation
|
||||
hasModels := false
|
||||
for i := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
hasModels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(file.FileDescriptorProto.Service) == 0 && !hasModels {
|
||||
return
|
||||
}
|
||||
|
||||
g.P("// Reference imports to suppress errors if they are not otherwise used.")
|
||||
g.P("var _ ", contextPkg, ".Context")
|
||||
g.P("var _ ", clientPkg, ".Option")
|
||||
g.P("var _ ", serverPkg, ".Option")
|
||||
if len(file.FileDescriptorProto.Service) > 0 {
|
||||
g.P("var _ ", clientPkg, ".Option")
|
||||
g.P("var _ ", serverPkg, ".Option")
|
||||
}
|
||||
if hasModels {
|
||||
g.P("var _ ", modelPkg, ".Database")
|
||||
}
|
||||
g.P()
|
||||
|
||||
for i, service := range file.FileDescriptorProto.Service {
|
||||
g.generateService(file, service, i)
|
||||
}
|
||||
|
||||
// Generate model structs for @model annotated messages
|
||||
for i, msg := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
g.generateModel(msg, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateImports generates the import declaration for this file.
|
||||
func (g *micro) GenerateImports(file *generator.FileDescriptor, imports map[generator.GoImportPath]generator.GoPackageName) {
|
||||
if len(file.FileDescriptorProto.Service) == 0 {
|
||||
hasServices := len(file.FileDescriptorProto.Service) > 0
|
||||
hasModels := false
|
||||
for i := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
hasModels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasServices && !hasModels {
|
||||
return
|
||||
}
|
||||
|
||||
g.P("import (")
|
||||
g.P(contextPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, contextPkgPath)))
|
||||
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
|
||||
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
|
||||
if hasServices {
|
||||
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
|
||||
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
|
||||
}
|
||||
if hasModels {
|
||||
g.P(modelPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, modelPkgPath)))
|
||||
}
|
||||
g.P(")")
|
||||
g.P()
|
||||
|
||||
@@ -529,3 +569,187 @@ func (g *micro) generateServerMethod(servName string, method *pb.MethodDescripto
|
||||
|
||||
return hname
|
||||
}
|
||||
|
||||
// isModelMessage checks if the message at the given index has a // @model annotation.
|
||||
// Path "4,<index>" refers to message_type[index] in FileDescriptorProto.
|
||||
func (g *micro) isModelMessage(msgIndex int) bool {
|
||||
commentPath := fmt.Sprintf("4,%d", msgIndex)
|
||||
comment, ok := g.gen.GetComments(commentPath)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(comment, "@model")
|
||||
}
|
||||
|
||||
// parseModelOptions extracts options from the @model annotation comment.
|
||||
// Supports: @model, @model(table=my_table), @model(key=custom_id)
|
||||
func parseModelOptions(comment string) (table string, key string) {
|
||||
idx := strings.Index(comment, "@model")
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
rest := comment[idx+len("@model"):]
|
||||
rest = strings.TrimSpace(rest)
|
||||
if !strings.HasPrefix(rest, "(") {
|
||||
return "", ""
|
||||
}
|
||||
end := strings.Index(rest, ")")
|
||||
if end < 0 {
|
||||
return "", ""
|
||||
}
|
||||
opts := rest[1:end]
|
||||
for _, part := range strings.Split(opts, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(kv[0]) {
|
||||
case "table":
|
||||
table = strings.TrimSpace(kv[1])
|
||||
case "key":
|
||||
key = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
return table, key
|
||||
}
|
||||
|
||||
// protoFieldGoType returns the Go type string for a proto field for use in model structs.
|
||||
// Only supports scalar types (no nested messages or enums in model structs).
|
||||
func protoFieldGoType(field *pb.FieldDescriptorProto) string {
|
||||
switch field.GetType() {
|
||||
case pb.FieldDescriptorProto_TYPE_DOUBLE:
|
||||
return "float64"
|
||||
case pb.FieldDescriptorProto_TYPE_FLOAT:
|
||||
return "float32"
|
||||
case pb.FieldDescriptorProto_TYPE_INT64, pb.FieldDescriptorProto_TYPE_SINT64, pb.FieldDescriptorProto_TYPE_SFIXED64:
|
||||
return "int64"
|
||||
case pb.FieldDescriptorProto_TYPE_UINT64, pb.FieldDescriptorProto_TYPE_FIXED64:
|
||||
return "uint64"
|
||||
case pb.FieldDescriptorProto_TYPE_INT32, pb.FieldDescriptorProto_TYPE_SINT32, pb.FieldDescriptorProto_TYPE_SFIXED32:
|
||||
return "int32"
|
||||
case pb.FieldDescriptorProto_TYPE_UINT32, pb.FieldDescriptorProto_TYPE_FIXED32:
|
||||
return "uint32"
|
||||
case pb.FieldDescriptorProto_TYPE_BOOL:
|
||||
return "bool"
|
||||
case pb.FieldDescriptorProto_TYPE_STRING:
|
||||
return "string"
|
||||
case pb.FieldDescriptorProto_TYPE_BYTES:
|
||||
return "[]byte"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// generateModel generates the model struct, factory, and proto conversion for a message.
|
||||
func (g *micro) generateModel(msg *pb.DescriptorProto, msgIndex int) {
|
||||
msgName := generator.CamelCase(msg.GetName())
|
||||
modelName := msgName + "Model"
|
||||
|
||||
// Parse options from comment
|
||||
commentPath := fmt.Sprintf("4,%d", msgIndex)
|
||||
comment, _ := g.gen.GetComments(commentPath)
|
||||
tableName, keyField := parseModelOptions(comment)
|
||||
|
||||
// Default table: lowercase message name + "s"
|
||||
if tableName == "" {
|
||||
tableName = strings.ToLower(msg.GetName()) + "s"
|
||||
}
|
||||
|
||||
// Default key: first field, or "id" if a field named "id" exists
|
||||
if keyField == "" {
|
||||
for _, field := range msg.Field {
|
||||
if field.GetName() == "id" {
|
||||
keyField = "id"
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyField == "" && len(msg.Field) > 0 {
|
||||
keyField = msg.Field[0].GetName()
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to scalar fields only (skip nested messages, maps, oneofs)
|
||||
type modelField struct {
|
||||
goName string
|
||||
jsonName string
|
||||
goType string
|
||||
isKey bool
|
||||
proto *pb.FieldDescriptorProto
|
||||
}
|
||||
var fields []modelField
|
||||
for _, field := range msg.Field {
|
||||
ft := field.GetType()
|
||||
// Skip message and enum types (not directly storable as scalars)
|
||||
if ft == pb.FieldDescriptorProto_TYPE_MESSAGE || ft == pb.FieldDescriptorProto_TYPE_GROUP {
|
||||
continue
|
||||
}
|
||||
// Skip repeated fields (slices aren't directly storable)
|
||||
if field.GetLabel() == pb.FieldDescriptorProto_LABEL_REPEATED {
|
||||
continue
|
||||
}
|
||||
goName := generator.CamelCase(field.GetName())
|
||||
jsonName := field.GetJsonName()
|
||||
if jsonName == "" {
|
||||
jsonName = field.GetName()
|
||||
}
|
||||
fields = append(fields, modelField{
|
||||
goName: goName,
|
||||
jsonName: jsonName,
|
||||
goType: protoFieldGoType(field),
|
||||
isKey: field.GetName() == keyField,
|
||||
proto: field,
|
||||
})
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate model struct
|
||||
g.P()
|
||||
g.P("// ", modelName, " is a model struct generated from ", msgName, ".")
|
||||
g.P("// Use New", modelName, " to create a typed model backed by any model.Database.")
|
||||
g.P("type ", modelName, " struct {")
|
||||
for _, f := range fields {
|
||||
tags := fmt.Sprintf("`json:%q", f.jsonName)
|
||||
if f.isKey {
|
||||
tags += ` model:"key"`
|
||||
}
|
||||
tags += "`"
|
||||
g.P(f.goName, " ", f.goType, " ", tags)
|
||||
}
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate factory: NewXModel(db) *model.Model[XModel]
|
||||
g.P("// New", modelName, " creates a typed model for ", msgName, " backed by the given database.")
|
||||
g.P("func New", modelName, "(db ", modelPkg, ".Database) *", modelPkg, ".Model[", modelName, "] {")
|
||||
g.P("return ", modelPkg, ".New[", modelName, "](db, ", modelPkg, `.WithTable("`, tableName, `"))`)
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate FromProto: XModelFromProto(*X) *XModel
|
||||
g.P("// ", modelName, "FromProto converts a ", msgName, " proto message to a ", modelName, ".")
|
||||
g.P("func ", modelName, "FromProto(p *", msgName, ") *", modelName, " {")
|
||||
g.P("if p == nil { return nil }")
|
||||
g.P("return &", modelName, "{")
|
||||
for _, f := range fields {
|
||||
getter := "Get" + f.goName
|
||||
g.P(f.goName, ": p.", getter, "(),")
|
||||
}
|
||||
g.P("}")
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate ToProto: (*XModel).ToProto() *X
|
||||
g.P("// ToProto converts a ", modelName, " to a ", msgName, " proto message.")
|
||||
g.P("func (m *", modelName, ") ToProto() *", msgName, " {")
|
||||
g.P("if m == nil { return nil }")
|
||||
g.P("return &", msgName, "{")
|
||||
for _, f := range fields {
|
||||
g.P(f.goName, ": m.", f.goName, ",")
|
||||
}
|
||||
g.P("}")
|
||||
g.P("}")
|
||||
g.P()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package micro
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseModelOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
comment string
|
||||
wantTable string
|
||||
wantKey string
|
||||
}{
|
||||
{" @model\n", "", ""},
|
||||
{" @model(table=app_users)\n", "app_users", ""},
|
||||
{" @model(key=user_id)\n", "", "user_id"},
|
||||
{" @model(table=users, key=user_id)\n", "users", "user_id"},
|
||||
{" some description\n @model(table=items)\n", "items", ""},
|
||||
{" no annotation here\n", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
table, key := parseModelOptions(tt.comment)
|
||||
if table != tt.wantTable {
|
||||
t.Errorf("parseModelOptions(%q): table = %q, want %q", tt.comment, table, tt.wantTable)
|
||||
}
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("parseModelOptions(%q): key = %q, want %q", tt.comment, key, tt.wantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoFieldGoType(t *testing.T) {
|
||||
// Smoke test - just verify it doesn't panic with nil
|
||||
typ := protoFieldGoType(nil)
|
||||
if typ != "string" {
|
||||
// nil field returns default based on zero value TYPE_DOUBLE=0
|
||||
t.Logf("protoFieldGoType(nil) = %q", typ)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/minio/highwayhash v1.0.3 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
|
||||
@@ -238,6 +238,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/micro/plugins/v5/auth/jwt v0.0.0-20250502062951-be3f35ce6464 h1:einNYloNFQ4h52c0CBvWv67frSq1xS0EUXCf1ncr1UM=
|
||||
github.com/micro/plugins/v5/auth/jwt v0.0.0-20250502062951-be3f35ce6464/go.mod h1:Mqqsr1LYrIiAuqKUI/C0sJRoIB80SATNBagcXjqK7oQ=
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 complete, most Q2 2026 features delivered, and core Q3 security features already in production. The model package now provides a unified AI provider interface (Anthropic + OpenAI) powering the agent playground.
|
||||
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 complete, most Q2 2026 features delivered, and core Q3 security features already in production. The ai package now provides a unified AI provider interface (Anthropic + OpenAI) powering the agent playground.
|
||||
|
||||
### Quick Status
|
||||
- **Q1 2026 (MCP Foundation):** COMPLETE (100%)
|
||||
@@ -73,13 +73,13 @@
|
||||
- Struct tag parsing for parameter descriptions
|
||||
- Manual override via `WithEndpointDocs()`
|
||||
|
||||
### Model Package (NEW - February 2026)
|
||||
- **`model.Model` interface** - Unified AI provider abstraction
|
||||
### AI Package (NEW - February 2026)
|
||||
- **`ai.Model` interface** - Unified AI provider abstraction
|
||||
- `Generate()` for request/response
|
||||
- `Stream()` for streaming responses
|
||||
- Tool execution with auto-calling support
|
||||
- **Anthropic Claude provider** (`model/anthropic`)
|
||||
- **OpenAI GPT provider** (`model/openai`)
|
||||
- **Anthropic Claude provider** (`ai/anthropic`)
|
||||
- **OpenAI GPT provider** (`ai/openai`)
|
||||
- Provider auto-detection from base URL
|
||||
- Powers the agent playground in `micro run`
|
||||
|
||||
@@ -244,7 +244,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
|
||||
- WebSocket transport (bidirectional JSON-RPC 2.0)
|
||||
- LangChain SDK (Python package in contrib/)
|
||||
- LlamaIndex SDK (Python package in contrib/ with RAG examples)
|
||||
- Model package with Anthropic + OpenAI providers
|
||||
- AI package with Anthropic + OpenAI providers
|
||||
|
||||
**REMAINING:**
|
||||
- Agent SDKs (AutoGPT)
|
||||
@@ -281,7 +281,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
|
||||
2. **[ROADMAP_2026.md](./ROADMAP_2026.md)** - AI-native roadmap with business model
|
||||
3. **[/gateway/mcp/DOCUMENTATION.md](./gateway/mcp/DOCUMENTATION.md)** - Complete MCP documentation
|
||||
4. **[/examples/mcp/README.md](./examples/mcp/README.md)** - Examples and usage guide
|
||||
5. **[/model/README.md](./model/README.md)** - Model package documentation
|
||||
5. **[/ai/README.md](./ai/README.md)** - AI package documentation
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +291,7 @@ Build compelling examples and demos that show agents interacting with go-micro s
|
||||
2. **Security-First** - Auth, scopes, audit from day one
|
||||
3. **Developer-Friendly** - 3 lines of code to enable MCP
|
||||
4. **Claude Code Ready** - Works with Anthropic's flagship IDE
|
||||
5. **Unified AI Model Interface** - Anthropic + OpenAI with tool auto-calling
|
||||
5. **Unified AI Interface** - Anthropic + OpenAI with tool auto-calling
|
||||
6. **Comprehensive Testing** - 90%+ test coverage
|
||||
7. **Well-Documented** - 90+ docs, examples, and blog post
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ The **Q1 2026: MCP Foundation** milestone is **COMPLETE** with significant progr
|
||||
| **CLI Integration** | ✅ COMPLETE | 100% |
|
||||
| **CLI Export Commands (Q2 Feature)** | ✅ COMPLETE | 100% |
|
||||
| **LangChain SDK (Q2 Feature)** | ✅ COMPLETE | 100% |
|
||||
| **Model Package (Q2 Feature)** | ✅ COMPLETE | 100% |
|
||||
| **AI Package (Q2 Feature)** | ✅ COMPLETE | 100% |
|
||||
| **Documentation Extraction** | ✅ COMPLETE | 100% |
|
||||
| **Tracing & Audit** | ✅ COMPLETE | 100% |
|
||||
| **Rate Limiting** | ✅ COMPLETE | 100% |
|
||||
@@ -237,7 +237,7 @@ This was planned for Q2 2026 but has been fully implemented:
|
||||
})
|
||||
```
|
||||
|
||||
### ✅ Model Package (Q2 2026 Feature)
|
||||
### ✅ AI Package (Q2 2026 Feature)
|
||||
|
||||
**Status:** COMPLETE (February 2026)
|
||||
|
||||
@@ -257,8 +257,8 @@ This was delivered as part of the agent integration push:
|
||||
```
|
||||
|
||||
2. **Providers:**
|
||||
- Anthropic Claude (`model/anthropic`) - Default: claude-sonnet-4-20250514
|
||||
- OpenAI GPT (`model/openai`) - Default: gpt-4o
|
||||
- Anthropic Claude (`ai/anthropic`) - Default: claude-sonnet-4-20250514
|
||||
- OpenAI GPT (`ai/openai`) - Default: gpt-4o
|
||||
- Provider auto-detection from base URL
|
||||
|
||||
3. **Tool Execution:**
|
||||
|
||||
@@ -163,14 +163,14 @@ Create official SDKs for popular agent frameworks:
|
||||
- [x] Example: Multi-agent workflow with go-micro services
|
||||
- [x] Published to contrib/langchain-go-micro/
|
||||
|
||||
#### Model Package ✅ COMPLETE
|
||||
- [x] `model.Model` interface with Generate and Stream
|
||||
- [x] Anthropic Claude provider (`model/anthropic`)
|
||||
- [x] OpenAI GPT provider (`model/openai`)
|
||||
#### AI Package ✅ COMPLETE
|
||||
- [x] `ai.Model` interface with Generate and Stream
|
||||
- [x] Anthropic Claude provider (`ai/anthropic`)
|
||||
- [x] OpenAI GPT provider (`ai/openai`)
|
||||
- [x] Tool execution with auto-calling support
|
||||
- [x] Provider auto-detection from base URL
|
||||
|
||||
**Why:** The model package powers the agent playground and enables services to call AI models directly.
|
||||
**Why:** The ai package powers the agent playground and enables services to call AI models directly.
|
||||
|
||||
#### LlamaIndex Integration ✅ COMPLETE
|
||||
- [x] `go-micro-llamaindex` package
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
layout: blog
|
||||
title: "The Model Package: Client, Server, and Now Data"
|
||||
permalink: /blog/6
|
||||
description: "Go Micro now has a typed data model layer — define structs, get CRUD and queries, swap backends. Every service gets Client, Server, and Model."
|
||||
---
|
||||
|
||||
# The Model Package: Client, Server, and Now Data
|
||||
|
||||
*March 4, 2026 — By the Go Micro Team*
|
||||
|
||||
Go Micro has always given you `service.Client()` to call other services and `service.Server()` to handle requests. But most services also need to save and query data. Until now, that meant either using the low-level `store` package (key-value only) or wiring up your own database layer.
|
||||
|
||||
Today we're shipping the `model` package — a typed data model layer that completes the service trifecta: **Client, Server, Model**.
|
||||
|
||||
## The Problem
|
||||
|
||||
The existing `store` package is great for simple key-value storage, but real services need more. You need to filter by fields, paginate results, count records, and use different databases in dev vs production. Most teams end up writing their own data layer or pulling in an ORM that has nothing to do with Go Micro.
|
||||
|
||||
We wanted something that feels native to the framework. Define a Go struct, tag a key, and get type-safe CRUD and queries — with the same pluggable backend pattern Go Micro uses everywhere.
|
||||
|
||||
## Define a Struct, Get a Database
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" model:"index"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
```
|
||||
|
||||
The `model:"key"` tag marks your primary key. The `model:"index"` tag creates an index for faster queries. Column names come from `json` tags (or lowercased field names if no tag).
|
||||
|
||||
Create a model and use it:
|
||||
|
||||
```go
|
||||
users := model.New[User](service.Model())
|
||||
|
||||
// Create
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
|
||||
|
||||
// Read
|
||||
user, err := users.Read(ctx, "1")
|
||||
|
||||
// Update
|
||||
user.Name = "Alice Smith"
|
||||
users.Update(ctx, user)
|
||||
|
||||
// Delete
|
||||
users.Delete(ctx, "1")
|
||||
```
|
||||
|
||||
No migrations. No connection setup. No configuration files. The schema is derived from your struct at startup.
|
||||
|
||||
## Queries That Feel Like Go
|
||||
|
||||
List and count with composable query options:
|
||||
|
||||
```go
|
||||
// Simple equality filter
|
||||
active, _ := users.List(ctx, model.Where("email", "alice@example.com"))
|
||||
|
||||
// Operators, ordering, pagination
|
||||
page, _ := users.List(ctx,
|
||||
model.WhereOp("age", ">=", 18),
|
||||
model.OrderDesc("name"),
|
||||
model.Limit(10),
|
||||
model.Offset(20),
|
||||
)
|
||||
|
||||
// Count records
|
||||
total, _ := users.Count(ctx, model.Where("age", 30))
|
||||
```
|
||||
|
||||
Filters support `=`, `!=`, `<`, `>`, `<=`, `>=`, and `LIKE`. Everything composes — add as many query options as you need.
|
||||
|
||||
## Three Backends, One Interface
|
||||
|
||||
The model layer follows Go Micro's pluggable pattern. Same code, different backends:
|
||||
|
||||
**Memory** — the default. Zero config, great for development and testing:
|
||||
|
||||
```go
|
||||
service := micro.New("users")
|
||||
users := model.New[User](service.Model()) // in-memory by default
|
||||
```
|
||||
|
||||
**SQLite** — single-file database for local development or single-node production:
|
||||
|
||||
```go
|
||||
db, _ := sqlite.New(model.WithDSN("file:app.db"))
|
||||
service := micro.New("users", micro.Model(db))
|
||||
```
|
||||
|
||||
**Postgres** — production-grade with connection pooling:
|
||||
|
||||
```go
|
||||
db, _ := postgres.New(model.WithDSN("postgres://localhost/myapp"))
|
||||
service := micro.New("users", micro.Model(db))
|
||||
```
|
||||
|
||||
Start with memory in dev, switch to SQLite or Postgres for production. Your application code doesn't change.
|
||||
|
||||
## The Complete Service Interface
|
||||
|
||||
The Service interface now has three core accessors:
|
||||
|
||||
```go
|
||||
type Service interface {
|
||||
Client() client.Client // Call other services
|
||||
Server() server.Server // Handle incoming requests
|
||||
Model() model.Database // Save and query data
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This means a typical service has everything it needs in one place:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
service := micro.New("users", micro.Address(":9001"))
|
||||
|
||||
// Data layer
|
||||
users := model.New[User](service.Model())
|
||||
|
||||
// Handler with data access
|
||||
service.Handle(&UserService{users: users})
|
||||
|
||||
// Run
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
Call services with `service.Client()`. Handle requests with `service.Server()`. Save data with `service.Model()`. That's the complete picture.
|
||||
|
||||
## Multiple Models, One Database
|
||||
|
||||
You can create multiple typed models from the same database connection:
|
||||
|
||||
```go
|
||||
db := service.Model()
|
||||
|
||||
users := model.New[User](db)
|
||||
posts := model.New[Post](db)
|
||||
comments := model.New[Comment](db)
|
||||
```
|
||||
|
||||
Each model gets its own table (derived from the struct name). They share the database connection.
|
||||
|
||||
## What's Next
|
||||
|
||||
The model package is production-ready with memory, SQLite, and Postgres backends. Coming soon:
|
||||
|
||||
- **Relationships** — define foreign keys between models
|
||||
- **Migrations** — track and apply schema changes
|
||||
- **Protobuf codegen** — `protoc-gen-micro` generates model code from proto definitions
|
||||
|
||||
See the [model documentation](https://go-micro.dev/docs/model.html) for the full API reference, or browse the [model package source](https://github.com/micro/go-micro/tree/master/model) to see the implementation.
|
||||
@@ -10,6 +10,13 @@ permalink: /blog/
|
||||
</div>
|
||||
|
||||
<div class="posts">
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/6">The Model Package: Client, Server, and Now Data</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">March 4, 2026</p>
|
||||
<p>Go Micro now has a typed data model layer — define structs, get CRUD and queries, swap backends. Every service gets Client, Server, and Model.</p>
|
||||
<a href="/blog/6">Read more →</a>
|
||||
</article>
|
||||
|
||||
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
|
||||
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/5">Developer Experience Cleanup: One Way to Do Things</a></h2>
|
||||
<p class="meta" style="color: #666; font-size: 0.85rem;">March 4, 2026</p>
|
||||
|
||||
@@ -78,7 +78,7 @@ Then issue different tokens:
|
||||
|
||||
## Pattern 3: Agent as Service Consumer
|
||||
|
||||
Your Go Micro service itself calls an AI model to process data, using the `model` package.
|
||||
Your Go Micro service itself calls an AI model to process data, using the `ai` package.
|
||||
|
||||
```
|
||||
User → API → Your Service → AI Model (Claude/GPT)
|
||||
@@ -89,20 +89,20 @@ User → API → Your Service → AI Model (Claude/GPT)
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/model"
|
||||
_ "go-micro.dev/v5/model/anthropic"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
)
|
||||
|
||||
type SummaryService struct {
|
||||
ai model.Model
|
||||
ai ai.Model
|
||||
tasks *TaskClient
|
||||
}
|
||||
|
||||
func NewSummaryService() *SummaryService {
|
||||
return &SummaryService{
|
||||
ai: model.New("anthropic",
|
||||
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
model.WithModel("claude-sonnet-4-20250514"),
|
||||
ai: ai.New("anthropic",
|
||||
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
ai.WithModel("claude-sonnet-4-20250514"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (s *SummaryService) Summarize(ctx context.Context, req *SummarizeRequest, r
|
||||
}
|
||||
|
||||
// Use AI to summarize
|
||||
resp, err := s.ai.Generate(ctx, &model.Request{
|
||||
resp, err := s.ai.Generate(ctx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Summarize these tasks:\n%s", formatTasks(tasks)),
|
||||
SystemPrompt: "You are a concise project manager. Summarize task status in 2-3 sentences.",
|
||||
})
|
||||
@@ -140,7 +140,7 @@ func (s *SummaryService) Summarize(ctx context.Context, req *SummarizeRequest, r
|
||||
|
||||
## Pattern 4: Agent with Tool Calling
|
||||
|
||||
An AI model calls your services as tools, with automatic tool execution via the model package.
|
||||
An AI model calls your services as tools, with automatic tool execution via the ai package.
|
||||
|
||||
```
|
||||
User → Your App → AI Model ←→ MCP Tools (your services)
|
||||
@@ -150,12 +150,12 @@ User → Your App → AI Model ←→ MCP Tools (your services)
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/model"
|
||||
_ "go-micro.dev/v5/model/anthropic"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
)
|
||||
|
||||
// Define tools from your service endpoints
|
||||
tools := []model.Tool{
|
||||
tools := []ai.Tool{
|
||||
{
|
||||
Name: "create_task",
|
||||
Description: "Create a new task with title and assignee",
|
||||
@@ -196,13 +196,13 @@ toolHandler := func(name string, input map[string]any) (any, string) {
|
||||
return nil, `{"error": "unknown tool"}`
|
||||
}
|
||||
|
||||
m := model.New("anthropic",
|
||||
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
model.WithToolHandler(toolHandler),
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
ai.WithToolHandler(toolHandler),
|
||||
)
|
||||
|
||||
// The model will automatically call tools and return the final answer
|
||||
resp, err := m.Generate(ctx, &model.Request{
|
||||
resp, err := m.Generate(ctx, &ai.Request{
|
||||
Prompt: "Create a task for Alice to review the PR and tell me what tasks she has",
|
||||
SystemPrompt: "You are a helpful project management assistant",
|
||||
Tools: tools,
|
||||
@@ -240,7 +240,7 @@ broker.Subscribe("tasks.created", func(p broker.Event) error {
|
||||
json.Unmarshal(p.Message().Body, &task)
|
||||
|
||||
// Use AI to auto-assign based on task content
|
||||
resp, err := ai.Generate(ctx, &model.Request{
|
||||
resp, err := aiModel.Generate(ctx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Who should handle this task? Title: %s, Description: %s. Team: alice (frontend), bob (backend), charlie (devops)", task.Title, task.Description),
|
||||
SystemPrompt: "Reply with just the username of the best person to handle this task.",
|
||||
})
|
||||
@@ -439,4 +439,4 @@ Keep services as pure business logic. Let the agent (or the agent framework) han
|
||||
- [Building AI-Native Services](ai-native-services.md) - End-to-end tutorial
|
||||
- [MCP Security Guide](mcp-security.md) - Auth and scopes
|
||||
- [Tool Description Best Practices](tool-descriptions.md) - Better docs for agents
|
||||
- [Model Package](../../model/README.md) - AI provider interface
|
||||
- [AI Package](../../ai/README.md) - AI provider interface
|
||||
|
||||
@@ -359,21 +359,21 @@ Each tool call generates a span with attributes:
|
||||
|
||||
Trace context is propagated downstream via metadata headers (`Mcp-Trace-Id`, `Mcp-Tool-Name`, `Mcp-Account-Id`), so you get full distributed traces from agent through gateway to service.
|
||||
|
||||
## Step 8: Use the Model Package (Optional)
|
||||
## Step 8: Use the AI Package (Optional)
|
||||
|
||||
If your service needs to call AI models directly:
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/model"
|
||||
_ "go-micro.dev/v5/model/anthropic"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
)
|
||||
|
||||
m := model.New("anthropic",
|
||||
model.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
)
|
||||
|
||||
resp, err := m.Generate(ctx, &model.Request{
|
||||
resp, err := m.Generate(ctx, &ai.Request{
|
||||
Prompt: "Summarize these tasks: " + taskJSON,
|
||||
SystemPrompt: "You are a project manager assistant",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
layout: doc
|
||||
title: Data Model
|
||||
permalink: /docs/model.html
|
||||
description: "Typed data model layer with CRUD operations, queries, and pluggable backends"
|
||||
---
|
||||
|
||||
# Data Model
|
||||
|
||||
The `model` package provides a typed data model layer for Go Micro services. Define Go structs, tag your fields, and get type-safe CRUD operations with queries, filtering, ordering, and pagination.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Title string `json:"title"`
|
||||
Done bool `json:"done"`
|
||||
Owner string `json:"owner" model:"index"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.New("tasks")
|
||||
|
||||
// Create a typed model backed by the service's database
|
||||
tasks := model.New[Task](service.Model())
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a record
|
||||
tasks.Create(ctx, &Task{ID: "1", Title: "Ship it", Owner: "alice"})
|
||||
|
||||
// Read by key
|
||||
task, _ := tasks.Read(ctx, "1")
|
||||
|
||||
// Update
|
||||
task.Done = true
|
||||
tasks.Update(ctx, task)
|
||||
|
||||
// List with filters
|
||||
aliceTasks, _ := tasks.List(ctx, model.Where("owner", "alice"))
|
||||
|
||||
// Delete
|
||||
tasks.Delete(ctx, "1")
|
||||
}
|
||||
```
|
||||
|
||||
## Defining Models
|
||||
|
||||
Models are plain Go structs. Use struct tags to control storage behavior:
|
||||
|
||||
| Tag | Purpose | Example |
|
||||
|-----|---------|---------|
|
||||
| `model:"key"` | Primary key field | `ID string \`model:"key"\`` |
|
||||
| `model:"index"` | Create an index on this field | `Email string \`model:"index"\`` |
|
||||
| `json:"name"` | Column name in the database | `Name string \`json:"name"\`` |
|
||||
|
||||
If no `model:"key"` tag is found, the package defaults to a field with `json:"id"` or a field named `ID`.
|
||||
|
||||
Table names are auto-derived from the struct name (lowercased + "s"), e.g. `User` → `users`. Override with `model.WithTable("custom_name")`.
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" model:"index"`
|
||||
Age int `json:"age"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Auto-derived table: "users"
|
||||
users := model.New[User](db)
|
||||
|
||||
// Custom table name
|
||||
users := model.New[User](db, model.WithTable("app_users"))
|
||||
```
|
||||
|
||||
## CRUD Operations
|
||||
|
||||
```go
|
||||
// Create — inserts a new record (returns ErrDuplicateKey if key exists)
|
||||
err := users.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
|
||||
// Read — retrieves by primary key (returns ErrNotFound if missing)
|
||||
user, err := users.Read(ctx, "1")
|
||||
|
||||
// Update — modifies an existing record (returns ErrNotFound if missing)
|
||||
user.Name = "Alice Smith"
|
||||
err = users.Update(ctx, user)
|
||||
|
||||
// Delete — removes by primary key (returns ErrNotFound if missing)
|
||||
err = users.Delete(ctx, "1")
|
||||
```
|
||||
|
||||
## Queries
|
||||
|
||||
Use query options to filter, order, and paginate results:
|
||||
|
||||
### Filters
|
||||
|
||||
```go
|
||||
// Equality
|
||||
results, _ := users.List(ctx, model.Where("email", "alice@example.com"))
|
||||
|
||||
// Operators: =, !=, <, >, <=, >=, LIKE
|
||||
results, _ = users.List(ctx, model.WhereOp("age", ">=", 18))
|
||||
results, _ = users.List(ctx, model.WhereOp("name", "LIKE", "Ali%"))
|
||||
|
||||
// Multiple filters (AND)
|
||||
results, _ = users.List(ctx,
|
||||
model.Where("owner", "alice"),
|
||||
model.WhereOp("age", ">", 25),
|
||||
)
|
||||
```
|
||||
|
||||
### Ordering
|
||||
|
||||
```go
|
||||
results, _ := users.List(ctx, model.OrderAsc("name"))
|
||||
results, _ = users.List(ctx, model.OrderDesc("created_at"))
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```go
|
||||
results, _ := users.List(ctx,
|
||||
model.Limit(10),
|
||||
model.Offset(20),
|
||||
)
|
||||
```
|
||||
|
||||
### Counting
|
||||
|
||||
```go
|
||||
total, _ := users.Count(ctx)
|
||||
active, _ := users.Count(ctx, model.Where("active", true))
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
The model layer uses Go Micro's pluggable interface pattern. All backends implement `model.Database`.
|
||||
|
||||
### Memory (Default)
|
||||
|
||||
Zero-config, in-memory storage. Data doesn't persist across restarts. Ideal for development and testing.
|
||||
|
||||
```go
|
||||
service := micro.New("myservice")
|
||||
tasks := model.New[Task](service.Model()) // memory backend by default
|
||||
```
|
||||
|
||||
Or create directly:
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model/memory"
|
||||
|
||||
db := memory.New()
|
||||
tasks := model.New[Task](db)
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
File-based database. Good for local development or single-node production.
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model/sqlite"
|
||||
|
||||
db, err := sqlite.New(model.WithDSN("file:app.db"))
|
||||
service := micro.New("myservice", micro.Model(db))
|
||||
```
|
||||
|
||||
### Postgres
|
||||
|
||||
Production-grade with connection pooling.
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model/postgres"
|
||||
|
||||
db, err := postgres.New(model.WithDSN("postgres://user:pass@localhost/myapp?sslmode=disable"))
|
||||
service := micro.New("myservice", micro.Model(db))
|
||||
```
|
||||
|
||||
## Service Integration
|
||||
|
||||
The `Service` interface provides `Model()` alongside `Client()` and `Server()`:
|
||||
|
||||
```go
|
||||
service := micro.New("users", micro.Address(":9001"))
|
||||
|
||||
// Access the three core components
|
||||
client := service.Client() // Call other services
|
||||
server := service.Server() // Handle requests
|
||||
db := service.Model() // Data persistence
|
||||
|
||||
// Create typed models from the shared database
|
||||
users := model.New[User](db)
|
||||
posts := model.New[Post](db)
|
||||
|
||||
// Use in your handler
|
||||
service.Handle(&UserHandler{users: users, posts: posts})
|
||||
service.Run()
|
||||
```
|
||||
|
||||
A handler that uses all three:
|
||||
|
||||
```go
|
||||
type OrderHandler struct {
|
||||
orders *model.Model[Order]
|
||||
client client.Client
|
||||
}
|
||||
|
||||
// CreateOrder saves an order and notifies the shipping service
|
||||
func (h *OrderHandler) CreateOrder(ctx context.Context, req *CreateReq, rsp *CreateRsp) error {
|
||||
// Save to database via Model
|
||||
order := &Order{ID: req.ID, Item: req.Item, Status: "pending"}
|
||||
if err := h.orders.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Call another service via Client
|
||||
shipClient := proto.NewShippingService("shipping", h.client)
|
||||
_, err := shipClient.Ship(ctx, &proto.ShipRequest{OrderID: order.ID})
|
||||
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The model package returns two sentinel errors:
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model"
|
||||
|
||||
// Check for not found
|
||||
user, err := users.Read(ctx, "missing")
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// record doesn't exist
|
||||
}
|
||||
|
||||
// Check for duplicate key
|
||||
err = users.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
err = users.Create(ctx, &User{ID: "1", Name: "Bob"})
|
||||
if errors.Is(err, model.ErrDuplicateKey) {
|
||||
// key "1" already exists
|
||||
}
|
||||
```
|
||||
|
||||
## Swapping Backends
|
||||
|
||||
Follow the standard Go Micro pattern — use in-memory for development, swap to a real database for production:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
var db model.Database
|
||||
|
||||
if os.Getenv("ENV") == "production" {
|
||||
db, _ = postgres.New(model.WithDSN(os.Getenv("DATABASE_URL")))
|
||||
} else {
|
||||
db = memory.New()
|
||||
}
|
||||
|
||||
service := micro.New("myservice", micro.Model(db))
|
||||
// ... same application code regardless of backend
|
||||
}
|
||||
```
|
||||
+119
-225
@@ -1,20 +1,8 @@
|
||||
# Model Package
|
||||
|
||||
The `model` package provides a simple, high-level interface for AI model providers like Anthropic Claude and OpenAI GPT.
|
||||
The `model` package provides a typed data model layer with CRUD operations, query filtering, and multiple database backends. It uses Go generics for type-safe access.
|
||||
|
||||
## Interface
|
||||
|
||||
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
|
||||
|
||||
```go
|
||||
type Model interface {
|
||||
Init(...Option) error
|
||||
Options() Options
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
String() string
|
||||
}
|
||||
```
|
||||
Unlike the `store` package (which is a raw KV abstraction), `model` provides structured data access with schema awareness, WHERE queries, ordering, pagination, and indexes.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -22,247 +10,153 @@ type Model interface {
|
||||
import (
|
||||
"context"
|
||||
"go-micro.dev/v5/model"
|
||||
_ "go-micro.dev/v5/model/anthropic"
|
||||
_ "go-micro.dev/v5/model/openai"
|
||||
"go-micro.dev/v5/model/memory"
|
||||
)
|
||||
|
||||
// Create a model
|
||||
m := model.New("openai",
|
||||
model.WithAPIKey("your-api-key"),
|
||||
model.WithModel("gpt-4o"),
|
||||
// Define your model with struct tags
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
// Create a database and model
|
||||
db := memory.New()
|
||||
users := model.New[User](db)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
|
||||
|
||||
// Read
|
||||
user, _ := users.Read(ctx, "1")
|
||||
fmt.Println(user.Name) // "Alice"
|
||||
|
||||
// Update
|
||||
user.Name = "Alice Smith"
|
||||
users.Update(ctx, user)
|
||||
|
||||
// Delete
|
||||
users.Delete(ctx, "1")
|
||||
```
|
||||
|
||||
## Struct Tags
|
||||
|
||||
| Tag | Description | Example |
|
||||
|-----|-------------|---------|
|
||||
| `model:"key"` | Primary key field | `ID string \`model:"key"\`` |
|
||||
| `model:"index"` | Create an index on this field | `Name string \`model:"index"\`` |
|
||||
| `json:"name"` | Column name in the database | `Name string \`json:"name"\`` |
|
||||
|
||||
If no `model:"key"` tag is found, the package defaults to a field with `json:"id"` or column name `id`.
|
||||
|
||||
## Querying
|
||||
|
||||
```go
|
||||
// Filter by field value
|
||||
users.List(ctx, model.Where("name", "Alice"))
|
||||
|
||||
// Comparison operators
|
||||
users.List(ctx, model.WhereOp("age", ">", 25))
|
||||
users.List(ctx, model.WhereOp("name", "LIKE", "Ali%"))
|
||||
|
||||
// Ordering
|
||||
users.List(ctx, model.OrderAsc("name"))
|
||||
users.List(ctx, model.OrderDesc("age"))
|
||||
|
||||
// Pagination
|
||||
users.List(ctx, model.Limit(10), model.Offset(20))
|
||||
|
||||
// Combine
|
||||
users.List(ctx,
|
||||
model.Where("status", "active"),
|
||||
model.WhereOp("age", ">=", 18),
|
||||
model.OrderDesc("created_at"),
|
||||
model.Limit(25),
|
||||
)
|
||||
|
||||
// Generate a response
|
||||
req := &model.Request{
|
||||
Prompt: "What is Go?",
|
||||
SystemPrompt: "You are a helpful programming assistant",
|
||||
}
|
||||
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Reply)
|
||||
// Count
|
||||
total, _ := users.Count(ctx)
|
||||
active, _ := users.Count(ctx, model.Where("status", "active"))
|
||||
```
|
||||
|
||||
## Options
|
||||
## Backends
|
||||
|
||||
Configure the model using functional options:
|
||||
### Memory (Development & Testing)
|
||||
|
||||
```go
|
||||
m := model.New("anthropic",
|
||||
model.WithAPIKey("your-key"), // Required
|
||||
model.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
|
||||
model.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
|
||||
)
|
||||
import "go-micro.dev/v5/model/memory"
|
||||
|
||||
db := memory.New()
|
||||
```
|
||||
|
||||
You can also update options after creation:
|
||||
In-memory storage. No persistence. Fast. Good for tests and prototyping.
|
||||
|
||||
### SQLite (Development & Single-Node Production)
|
||||
|
||||
```go
|
||||
m.Init(
|
||||
model.WithModel("gpt-4o-mini"),
|
||||
model.WithAPIKey("new-key"),
|
||||
)
|
||||
import "go-micro.dev/v5/model/sqlite"
|
||||
|
||||
db := sqlite.New("app.db") // File-based
|
||||
db := sqlite.New(":memory:") // In-memory (testing)
|
||||
```
|
||||
|
||||
## Using Tools
|
||||
Embedded SQL database. Zero external dependencies. Supports WHERE, indexes, ordering natively.
|
||||
|
||||
The model can automatically execute tool calls when provided with a tool handler:
|
||||
### Postgres (Production)
|
||||
|
||||
```go
|
||||
// Define a tool handler
|
||||
toolHandler := func(name string, input map[string]any) (result any, content string) {
|
||||
// Execute the tool and return results
|
||||
switch name {
|
||||
case "get_weather":
|
||||
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
|
||||
default:
|
||||
return nil, `{"error": "unknown tool"}`
|
||||
}
|
||||
}
|
||||
import "go-micro.dev/v5/model/postgres"
|
||||
|
||||
// Create model with tool handler
|
||||
m := model.New("openai",
|
||||
model.WithAPIKey("your-key"),
|
||||
model.WithToolHandler(toolHandler),
|
||||
)
|
||||
|
||||
// Provide tools in the request
|
||||
req := &model.Request{
|
||||
Prompt: "What's the weather?",
|
||||
SystemPrompt: "You are a helpful assistant",
|
||||
Tools: []model.Tool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Properties: map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate will automatically call tools and return final answer
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
fmt.Println(resp.Answer) // Final answer after tool execution
|
||||
db := postgres.New("postgres://user:pass@localhost/mydb?sslmode=disable")
|
||||
```
|
||||
|
||||
## Response Structure
|
||||
Full PostgreSQL support. Best for production with rich query capabilities.
|
||||
|
||||
## Table Names
|
||||
|
||||
By default, the table name is the lowercase struct name + "s" (e.g., `User` → `users`). Override with `WithTable`:
|
||||
|
||||
```go
|
||||
type Response struct {
|
||||
Reply string // Initial reply from model
|
||||
ToolCalls []ToolCall // Tools the model wants to call
|
||||
Answer string // Final answer (after tool execution if handler provided)
|
||||
users := model.New[User](db, model.WithTable("app_users"))
|
||||
```
|
||||
|
||||
## Database Interface
|
||||
|
||||
All backends implement the `model.Database` interface:
|
||||
|
||||
```go
|
||||
type Database interface {
|
||||
Init(...Option) error
|
||||
NewTable(schema *Schema) error
|
||||
Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error
|
||||
Read(ctx context.Context, schema *Schema, key string) (map[string]any, error)
|
||||
Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error
|
||||
Delete(ctx context.Context, schema *Schema, key string) error
|
||||
List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error)
|
||||
Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error)
|
||||
Close() error
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
- `Reply`: The model's first response
|
||||
- `ToolCalls`: List of tools the model requested (if any)
|
||||
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
|
||||
## Model vs Store
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
```go
|
||||
m := model.New("anthropic",
|
||||
model.WithAPIKey("sk-ant-..."),
|
||||
model.WithModel("claude-sonnet-4-20250514"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `claude-sonnet-4-20250514`
|
||||
Default base URL: `https://api.anthropic.com`
|
||||
|
||||
### OpenAI GPT
|
||||
|
||||
```go
|
||||
m := model.New("openai",
|
||||
model.WithAPIKey("sk-..."),
|
||||
model.WithModel("gpt-4o"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `gpt-4o`
|
||||
Default base URL: `https://api.openai.com`
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
Use `AutoDetectProvider()` to detect the provider from a base URL:
|
||||
|
||||
```go
|
||||
provider := model.AutoDetectProvider("https://api.anthropic.com")
|
||||
// Returns "anthropic"
|
||||
|
||||
m := model.New(provider, model.WithAPIKey("..."))
|
||||
```
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
1. Create a new package under `model/`:
|
||||
|
||||
```go
|
||||
package myprovider
|
||||
|
||||
import "go-micro.dev/v5/model"
|
||||
|
||||
func init() {
|
||||
model.Register("myprovider", func(opts ...model.Option) model.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts model.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...model.Option) *Provider {
|
||||
options := model.NewOptions(opts...)
|
||||
// Set defaults
|
||||
if options.Model == "" {
|
||||
options.Model = "my-default-model"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.myprovider.com"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
func (p *Provider) Init(opts ...model.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Options() model.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
func (p *Provider) String() string {
|
||||
return "myprovider"
|
||||
}
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (*model.Response, error) {
|
||||
// Implement your provider logic
|
||||
// - Build API request
|
||||
// - Make HTTP call
|
||||
// - Parse response
|
||||
// - Handle tools if ToolHandler is set
|
||||
return &model.Response{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *model.Request, opts ...model.GenerateOption) (model.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
2. Import your provider:
|
||||
|
||||
```go
|
||||
import _ "go-micro.dev/v5/model/myprovider"
|
||||
```
|
||||
|
||||
## Comparison with Other Packages
|
||||
|
||||
The model package follows the same patterns as other go-micro packages:
|
||||
|
||||
**Registry:**
|
||||
```go
|
||||
r := registry.NewRegistry(registry.Addrs("..."))
|
||||
r.Register(service)
|
||||
```
|
||||
|
||||
**Client:**
|
||||
```go
|
||||
c := client.NewClient(client.Retries(3))
|
||||
c.Call(ctx, req, rsp)
|
||||
```
|
||||
|
||||
**Model:**
|
||||
```go
|
||||
m := model.New("openai", model.WithAPIKey("..."))
|
||||
m.Generate(ctx, req)
|
||||
```
|
||||
|
||||
All use:
|
||||
- `Init()` to update options
|
||||
- `Options()` to get current options
|
||||
- `String()` to get the implementation name
|
||||
- Functional options pattern
|
||||
| Feature | `store` | `model` |
|
||||
|---------|---------|---------|
|
||||
| Data format | Raw `[]byte` | Typed Go structs |
|
||||
| Queries | Key prefix/suffix only | WHERE, operators, LIKE |
|
||||
| Ordering | None | ORDER BY field ASC/DESC |
|
||||
| Pagination | Limit/Offset on keys | Limit/Offset on results |
|
||||
| Indexes | None | Via `model:"index"` tag |
|
||||
| Schema | None (schemaless KV) | Auto-created from struct |
|
||||
| Backends | Memory, File, MySQL, Postgres, NATS | Memory, SQLite, Postgres |
|
||||
| Use case | Config, sessions, cache | Application data, entities |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./model/...
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the [server implementation](../../cmd/micro/server/server.go) for a complete example of using the model package with tool execution.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Package memory provides an in-memory Database implementation for the model package.
|
||||
// Useful for testing and development. Data does not persist across restarts.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Database is an in-memory model.Database implementation.
|
||||
type Database struct {
|
||||
mu sync.RWMutex
|
||||
tables map[string]*table
|
||||
}
|
||||
|
||||
type table struct {
|
||||
rows map[string]map[string]any
|
||||
}
|
||||
|
||||
// New creates a new in-memory database.
|
||||
func New(opts ...model.Option) *Database {
|
||||
return &Database{
|
||||
tables: make(map[string]*table),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) Init(opts ...model.Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) NewTable(schema *model.Schema) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if _, ok := d.tables[schema.Table]; !ok {
|
||||
d.tables[schema.Table] = &table{rows: make(map[string]map[string]any)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
t := d.tables[schema.Table]
|
||||
if _, exists := t.rows[key]; exists {
|
||||
return model.ErrDuplicateKey
|
||||
}
|
||||
// Copy the map to avoid external mutation
|
||||
row := make(map[string]any, len(fields))
|
||||
for k, v := range fields {
|
||||
row[k] = v
|
||||
}
|
||||
t.rows[key] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
t := d.tables[schema.Table]
|
||||
row, ok := t.rows[key]
|
||||
if !ok {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
// Return a copy
|
||||
result := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
result[k] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
t := d.tables[schema.Table]
|
||||
if _, ok := t.rows[key]; !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
row := make(map[string]any, len(fields))
|
||||
for k, v := range fields {
|
||||
row[k] = v
|
||||
}
|
||||
t.rows[key] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
t := d.tables[schema.Table]
|
||||
if _, ok := t.rows[key]; !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
delete(t.rows, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
t := d.tables[schema.Table]
|
||||
|
||||
var results []map[string]any
|
||||
for _, row := range t.rows {
|
||||
if matchFilters(row, q.Filters) {
|
||||
// Copy row
|
||||
cp := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
cp[k] = v
|
||||
}
|
||||
results = append(results, cp)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort if OrderBy is set
|
||||
if q.OrderBy != "" {
|
||||
sortRows(results, q.OrderBy, q.Desc)
|
||||
}
|
||||
|
||||
// Apply offset
|
||||
if q.Offset > 0 && uint(len(results)) > q.Offset {
|
||||
results = results[q.Offset:]
|
||||
} else if q.Offset > 0 {
|
||||
results = nil
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if q.Limit > 0 && uint(len(results)) > q.Limit {
|
||||
results = results[:q.Limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
t := d.tables[schema.Table]
|
||||
|
||||
var count int64
|
||||
for _, row := range t.rows {
|
||||
if matchFilters(row, q.Filters) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) String() string {
|
||||
return "memory"
|
||||
}
|
||||
|
||||
// matchFilters returns true if the row satisfies all filters.
|
||||
func matchFilters(row map[string]any, filters []model.Filter) bool {
|
||||
for _, f := range filters {
|
||||
val, ok := row[f.Field]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !compareValues(val, f.Op, f.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compareValues compares two values with the given operator.
|
||||
func compareValues(a any, op string, b any) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return fmt.Sprint(a) == fmt.Sprint(b)
|
||||
case "!=":
|
||||
return fmt.Sprint(a) != fmt.Sprint(b)
|
||||
case "LIKE":
|
||||
// Simple LIKE: supports % wildcard at start/end
|
||||
pattern := fmt.Sprint(b)
|
||||
val := fmt.Sprint(a)
|
||||
if strings.HasPrefix(pattern, "%") && strings.HasSuffix(pattern, "%") {
|
||||
return strings.Contains(val, pattern[1:len(pattern)-1])
|
||||
}
|
||||
if strings.HasPrefix(pattern, "%") {
|
||||
return strings.HasSuffix(val, pattern[1:])
|
||||
}
|
||||
if strings.HasSuffix(pattern, "%") {
|
||||
return strings.HasPrefix(val, pattern[:len(pattern)-1])
|
||||
}
|
||||
return val == pattern
|
||||
case "<", ">", "<=", ">=":
|
||||
return compareNumeric(a, op, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// compareNumeric attempts numeric comparison.
|
||||
func compareNumeric(a any, op string, b any) bool {
|
||||
af, aOk := toFloat64(a)
|
||||
bf, bOk := toFloat64(b)
|
||||
if !aOk || !bOk {
|
||||
// Fall back to string comparison
|
||||
as, bs := fmt.Sprint(a), fmt.Sprint(b)
|
||||
switch op {
|
||||
case "<":
|
||||
return as < bs
|
||||
case ">":
|
||||
return as > bs
|
||||
case "<=":
|
||||
return as <= bs
|
||||
case ">=":
|
||||
return as >= bs
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case "<":
|
||||
return af < bf
|
||||
case ">":
|
||||
return af > bf
|
||||
case "<=":
|
||||
return af <= bf
|
||||
case ">=":
|
||||
return af >= bf
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(rv.Int()), true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(rv.Uint()), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float(), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// sortRows sorts rows by a field. Simple insertion sort for small datasets.
|
||||
func sortRows(rows []map[string]any, field string, desc bool) {
|
||||
for i := 1; i < len(rows); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a := fmt.Sprint(rows[j-1][field])
|
||||
b := fmt.Sprint(rows[j][field])
|
||||
shouldSwap := a > b
|
||||
if desc {
|
||||
shouldSwap = a < b
|
||||
}
|
||||
if shouldSwap {
|
||||
rows[j-1], rows[j] = rows[j], rows[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func setup(t *testing.T) *model.Model[User] {
|
||||
t.Helper()
|
||||
db := New()
|
||||
return model.New[User](db)
|
||||
}
|
||||
|
||||
func TestCRUD(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
u, err := users.Read(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if u.Name != "Alice" {
|
||||
t.Errorf("expected Alice, got %s", u.Name)
|
||||
}
|
||||
if u.Age != 30 {
|
||||
t.Errorf("expected age 30, got %d", u.Age)
|
||||
}
|
||||
|
||||
// Update
|
||||
u.Name = "Alice Updated"
|
||||
u.Age = 31
|
||||
err = users.Update(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
u2, _ := users.Read(ctx, "1")
|
||||
if u2.Name != "Alice Updated" {
|
||||
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
|
||||
}
|
||||
if u2.Age != 31 {
|
||||
t.Errorf("expected age 31, got %d", u2.Age)
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = users.Delete(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
_, err = users.Read(ctx, "1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKey(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
err := users.Create(ctx, &User{ID: "1", Name: "Bob"})
|
||||
if err != model.ErrDuplicateKey {
|
||||
t.Errorf("expected ErrDuplicateKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := users.Read(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
err = users.Update(ctx, &User{ID: "nonexistent"})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on update, got %v", err)
|
||||
}
|
||||
|
||||
err = users.Delete(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
// List all
|
||||
all, err := users.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Errorf("expected 3, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithFilter(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
// Filter by name
|
||||
results, err := users.List(ctx, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("list with filter: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 Alices, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithLimitOffset(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
|
||||
users.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
|
||||
users.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
|
||||
users.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
|
||||
|
||||
// Sort by name, get 2 starting from offset 1
|
||||
results, err := users.List(ctx,
|
||||
model.OrderAsc("name"),
|
||||
model.Limit(2),
|
||||
model.Offset(1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "B" {
|
||||
t.Errorf("expected B, got %s", results[0].Name)
|
||||
}
|
||||
if results[1].Name != "C" {
|
||||
t.Errorf("expected C, got %s", results[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCount(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
count, err := users.Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
|
||||
count, err = users.Count(ctx, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("count with filter: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereOp(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
// Age > 28
|
||||
results, err := users.List(ctx, model.WhereOp("age", ">", 28))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 (age > 28), got %d", len(results))
|
||||
}
|
||||
}
|
||||
+223
-102
@@ -1,130 +1,251 @@
|
||||
// Package model provides abstraction for AI model providers
|
||||
// Package model provides a typed data model layer with CRUD operations and query support.
|
||||
// It uses Go generics for type-safe access and supports multiple backends (memory, SQLite, Postgres).
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model provides an interface for interacting with AI model providers
|
||||
type Model interface {
|
||||
// Init initializes the model with options
|
||||
var (
|
||||
// ErrNotFound is returned when a record doesn't exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrDuplicateKey is returned when a record with the same key already exists.
|
||||
ErrDuplicateKey = errors.New("duplicate key")
|
||||
)
|
||||
|
||||
// Database is the backend interface that model implementations must satisfy.
|
||||
// Each backend (memory, sqlite, postgres) implements this interface.
|
||||
type Database interface {
|
||||
// Init initializes the database connection.
|
||||
Init(...Option) error
|
||||
// Options returns the model options
|
||||
Options() Options
|
||||
// Generate generates a response from the model
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
// Stream generates a streaming response (for future implementation)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
// String returns the name of the provider
|
||||
// NewTable ensures the table exists for the given schema.
|
||||
NewTable(schema *Schema) error
|
||||
// Create inserts a new record. Returns ErrDuplicateKey if key exists.
|
||||
Create(ctx context.Context, schema *Schema, key string, fields map[string]any) error
|
||||
// Read returns a single record by key. Returns ErrNotFound if missing.
|
||||
Read(ctx context.Context, schema *Schema, key string) (map[string]any, error)
|
||||
// Update modifies an existing record by key. Returns ErrNotFound if missing.
|
||||
Update(ctx context.Context, schema *Schema, key string, fields map[string]any) error
|
||||
// Delete removes a record by key. Returns ErrNotFound if missing.
|
||||
Delete(ctx context.Context, schema *Schema, key string) error
|
||||
// List returns all records matching the query options.
|
||||
List(ctx context.Context, schema *Schema, opts ...QueryOption) ([]map[string]any, error)
|
||||
// Count returns the number of records matching the query options.
|
||||
Count(ctx context.Context, schema *Schema, opts ...QueryOption) (int64, error)
|
||||
// Close closes the database connection.
|
||||
Close() error
|
||||
// String returns the implementation name.
|
||||
String() string
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that can be called by the model
|
||||
type Tool struct {
|
||||
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
|
||||
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
|
||||
Description string
|
||||
Properties map[string]any // JSON schema for tool parameters
|
||||
// Schema describes a model's storage layout, derived from struct tags.
|
||||
type Schema struct {
|
||||
// Table name in the database.
|
||||
Table string
|
||||
// Key is the name of the primary key field.
|
||||
Key string
|
||||
// Fields maps Go field names to their column metadata.
|
||||
Fields []Field
|
||||
}
|
||||
|
||||
// Request represents a request to generate content from a model
|
||||
type Request struct {
|
||||
// Prompt is the user's message/prompt
|
||||
Prompt string
|
||||
// SystemPrompt is the system instruction for the model
|
||||
SystemPrompt string
|
||||
// Tools available for the model to use
|
||||
Tools []Tool
|
||||
// Messages for continuing a conversation (optional)
|
||||
Messages []Message
|
||||
// Field describes a single field in the schema.
|
||||
type Field struct {
|
||||
// Name is the Go struct field name.
|
||||
Name string
|
||||
// Column is the database column name (from json tag or lowercased name).
|
||||
Column string
|
||||
// Type is the Go reflect type.
|
||||
Type reflect.Type
|
||||
// IsKey indicates this is the primary key field.
|
||||
IsKey bool
|
||||
// Index indicates this field should be indexed.
|
||||
Index bool
|
||||
}
|
||||
|
||||
// Message represents a conversation message
|
||||
type Message struct {
|
||||
Role string // "user", "assistant", "system", "tool"
|
||||
Content any // Can be string or structured content
|
||||
// Model provides typed CRUD operations for a specific Go struct type.
|
||||
type Model[T any] struct {
|
||||
db Database
|
||||
schema *Schema
|
||||
}
|
||||
|
||||
// Response represents the response from a model
|
||||
type Response struct {
|
||||
// Reply is the text response from the model
|
||||
Reply string
|
||||
// ToolCalls are tool calls requested by the model
|
||||
ToolCalls []ToolCall
|
||||
// Answer is the final answer after tool execution (if tools were used)
|
||||
Answer string
|
||||
}
|
||||
// New creates a new Model for the given type T, backed by the provided database.
|
||||
// T must be a struct with at least one field tagged `model:"key"`.
|
||||
func New[T any](db Database, opts ...ModelOption) *Model[T] {
|
||||
var t T
|
||||
schema := buildSchema(reflect.TypeOf(t))
|
||||
|
||||
// ToolCall represents a request to call a tool
|
||||
type ToolCall struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Name string // Tool name
|
||||
Input map[string]any // Tool input arguments
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Content string // Tool execution result (JSON string)
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
// Close closes the stream
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles tool calls
|
||||
type ToolHandler func(name string, input map[string]any) (result any, content string)
|
||||
|
||||
// NewFunc creates a new Model instance
|
||||
type NewFunc func(...Option) Model
|
||||
|
||||
var providers = make(map[string]NewFunc)
|
||||
|
||||
// Register registers a model provider
|
||||
func Register(name string, fn NewFunc) {
|
||||
providers[name] = fn
|
||||
}
|
||||
|
||||
// New creates a new Model instance based on the provider name
|
||||
func New(provider string, opts ...Option) Model {
|
||||
if fn, ok := providers[provider]; ok {
|
||||
return fn(opts...)
|
||||
// Apply model options
|
||||
for _, o := range opts {
|
||||
o(schema)
|
||||
}
|
||||
|
||||
// Default to first registered provider
|
||||
if len(providers) > 0 {
|
||||
for _, fn := range providers {
|
||||
return fn(opts...)
|
||||
|
||||
// Ensure table exists
|
||||
if err := db.NewTable(schema); err != nil {
|
||||
panic(fmt.Sprintf("model: failed to create table %q: %v", schema.Table, err))
|
||||
}
|
||||
|
||||
return &Model[T]{
|
||||
db: db,
|
||||
schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts a new record.
|
||||
func (m *Model[T]) Create(ctx context.Context, v *T) error {
|
||||
fields := structToMap(m.schema, v)
|
||||
key, ok := fields[m.schema.Key]
|
||||
if !ok {
|
||||
return fmt.Errorf("model: key field %q not set", m.schema.Key)
|
||||
}
|
||||
return m.db.Create(ctx, m.schema, fmt.Sprint(key), fields)
|
||||
}
|
||||
|
||||
// Read retrieves a record by its primary key.
|
||||
func (m *Model[T]) Read(ctx context.Context, key string) (*T, error) {
|
||||
fields, err := m.db.Read(ctx, m.schema, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := mapToStruct[T](m.schema, fields)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Update modifies an existing record.
|
||||
func (m *Model[T]) Update(ctx context.Context, v *T) error {
|
||||
fields := structToMap(m.schema, v)
|
||||
key, ok := fields[m.schema.Key]
|
||||
if !ok {
|
||||
return fmt.Errorf("model: key field %q not set", m.schema.Key)
|
||||
}
|
||||
return m.db.Update(ctx, m.schema, fmt.Sprint(key), fields)
|
||||
}
|
||||
|
||||
// Delete removes a record by its primary key.
|
||||
func (m *Model[T]) Delete(ctx context.Context, key string) error {
|
||||
return m.db.Delete(ctx, m.schema, key)
|
||||
}
|
||||
|
||||
// List returns records matching the query options.
|
||||
func (m *Model[T]) List(ctx context.Context, opts ...QueryOption) ([]*T, error) {
|
||||
rows, err := m.db.List(ctx, m.schema, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]*T, len(rows))
|
||||
for i, row := range rows {
|
||||
results[i] = mapToStruct[T](m.schema, row)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Count returns the number of records matching the query options.
|
||||
func (m *Model[T]) Count(ctx context.Context, opts ...QueryOption) (int64, error) {
|
||||
return m.db.Count(ctx, m.schema, opts...)
|
||||
}
|
||||
|
||||
// Schema returns the model's schema (useful for debugging/introspection).
|
||||
func (m *Model[T]) Schema() *Schema {
|
||||
return m.schema
|
||||
}
|
||||
|
||||
// buildSchema extracts the Schema from a struct type using reflection.
|
||||
func buildSchema(t reflect.Type) *Schema {
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
schema := &Schema{
|
||||
Table: strings.ToLower(t.Name()) + "s",
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
field := Field{
|
||||
Name: f.Name,
|
||||
Type: f.Type,
|
||||
}
|
||||
|
||||
// Column name: use json tag if present, else lowercase field name
|
||||
if tag := f.Tag.Get("json"); tag != "" {
|
||||
parts := strings.Split(tag, ",")
|
||||
if parts[0] != "" && parts[0] != "-" {
|
||||
field.Column = parts[0]
|
||||
}
|
||||
}
|
||||
if field.Column == "" {
|
||||
field.Column = strings.ToLower(f.Name)
|
||||
}
|
||||
|
||||
// Check model tag
|
||||
if tag := f.Tag.Get("model"); tag != "" {
|
||||
for _, opt := range strings.Split(tag, ",") {
|
||||
switch opt {
|
||||
case "key":
|
||||
field.IsKey = true
|
||||
schema.Key = field.Column
|
||||
case "index":
|
||||
field.Index = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schema.Fields = append(schema.Fields, field)
|
||||
}
|
||||
|
||||
if schema.Key == "" {
|
||||
// Default to "id" if no key tag found
|
||||
for i := range schema.Fields {
|
||||
if schema.Fields[i].Column == "id" {
|
||||
schema.Fields[i].IsKey = true
|
||||
schema.Key = "id"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
// AutoDetectProvider attempts to detect the provider from the base URL
|
||||
func AutoDetectProvider(baseURL string) string {
|
||||
if baseURL == "" {
|
||||
return "openai"
|
||||
// structToMap converts a struct to a map of column name → value.
|
||||
func structToMap[T any](schema *Schema, v *T) map[string]any {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
fields := make(map[string]any, len(schema.Fields))
|
||||
for _, f := range schema.Fields {
|
||||
fv := rv.FieldByName(f.Name)
|
||||
if fv.IsValid() {
|
||||
fields[f.Column] = fv.Interface()
|
||||
}
|
||||
}
|
||||
// Simple detection based on URL
|
||||
if strings.Contains(baseURL, "anthropic") {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
return fields
|
||||
}
|
||||
|
||||
// DefaultModel is a default model instance
|
||||
var DefaultModel Model
|
||||
|
||||
// Generate generates a response using the default model
|
||||
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
if DefaultModel == nil {
|
||||
return nil, nil
|
||||
// mapToStruct converts a map of column name → value back to a struct.
|
||||
func mapToStruct[T any](schema *Schema, fields map[string]any) *T {
|
||||
v := new(T)
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
for _, f := range schema.Fields {
|
||||
val, ok := fields[f.Column]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fv := rv.FieldByName(f.Name)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
rval := reflect.ValueOf(val)
|
||||
if rval.Type().AssignableTo(fv.Type()) {
|
||||
fv.Set(rval)
|
||||
} else if rval.Type().ConvertibleTo(fv.Type()) {
|
||||
fv.Set(rval.Convert(fv.Type()))
|
||||
}
|
||||
}
|
||||
return DefaultModel.Generate(ctx, req, opts...)
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type TestUser struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func TestBuildSchema(t *testing.T) {
|
||||
schema := buildSchema(reflect.TypeOf(TestUser{}))
|
||||
|
||||
if schema.Table != "testusers" {
|
||||
t.Errorf("expected table 'testusers', got %q", schema.Table)
|
||||
}
|
||||
if schema.Key != "id" {
|
||||
t.Errorf("expected key 'id', got %q", schema.Key)
|
||||
}
|
||||
if len(schema.Fields) != 4 {
|
||||
t.Fatalf("expected 4 fields, got %d", len(schema.Fields))
|
||||
}
|
||||
|
||||
// Check key field
|
||||
var keyField Field
|
||||
var indexField Field
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
keyField = f
|
||||
}
|
||||
if f.Index {
|
||||
indexField = f
|
||||
}
|
||||
}
|
||||
if keyField.Column != "id" {
|
||||
t.Errorf("expected key column 'id', got %q", keyField.Column)
|
||||
}
|
||||
if indexField.Column != "name" {
|
||||
t.Errorf("expected index column 'name', got %q", indexField.Column)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchema_DefaultKey(t *testing.T) {
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
schema := buildSchema(reflect.TypeOf(Item{}))
|
||||
if schema.Key != "id" {
|
||||
t.Errorf("expected default key 'id', got %q", schema.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchema_WithTable(t *testing.T) {
|
||||
schema := buildSchema(reflect.TypeOf(TestUser{}))
|
||||
WithTable("my_users")(schema)
|
||||
|
||||
if schema.Table != "my_users" {
|
||||
t.Errorf("expected table 'my_users', got %q", schema.Table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructToMap(t *testing.T) {
|
||||
schema := buildSchema(reflect.TypeOf(TestUser{}))
|
||||
u := &TestUser{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}
|
||||
|
||||
m := structToMap(schema, u)
|
||||
|
||||
if m["id"] != "1" {
|
||||
t.Errorf("expected id '1', got %v", m["id"])
|
||||
}
|
||||
if m["name"] != "Alice" {
|
||||
t.Errorf("expected name 'Alice', got %v", m["name"])
|
||||
}
|
||||
if m["email"] != "alice@example.com" {
|
||||
t.Errorf("expected email 'alice@example.com', got %v", m["email"])
|
||||
}
|
||||
if m["age"] != 30 {
|
||||
t.Errorf("expected age 30, got %v", m["age"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToStruct(t *testing.T) {
|
||||
schema := buildSchema(reflect.TypeOf(TestUser{}))
|
||||
m := map[string]any{
|
||||
"id": "1",
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"age": 25,
|
||||
}
|
||||
|
||||
u := mapToStruct[TestUser](schema, m)
|
||||
|
||||
if u.ID != "1" {
|
||||
t.Errorf("expected ID '1', got %q", u.ID)
|
||||
}
|
||||
if u.Name != "Bob" {
|
||||
t.Errorf("expected Name 'Bob', got %q", u.Name)
|
||||
}
|
||||
if u.Email != "bob@example.com" {
|
||||
t.Errorf("expected Email 'bob@example.com', got %q", u.Email)
|
||||
}
|
||||
if u.Age != 25 {
|
||||
t.Errorf("expected Age 25, got %d", u.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyQueryOptions(t *testing.T) {
|
||||
q := ApplyQueryOptions(
|
||||
Where("name", "Alice"),
|
||||
WhereOp("age", ">", 20),
|
||||
OrderDesc("name"),
|
||||
Limit(10),
|
||||
Offset(5),
|
||||
)
|
||||
|
||||
if len(q.Filters) != 2 {
|
||||
t.Fatalf("expected 2 filters, got %d", len(q.Filters))
|
||||
}
|
||||
if q.Filters[0].Field != "name" || q.Filters[0].Op != "=" || q.Filters[0].Value != "Alice" {
|
||||
t.Errorf("unexpected filter 0: %+v", q.Filters[0])
|
||||
}
|
||||
if q.Filters[1].Field != "age" || q.Filters[1].Op != ">" {
|
||||
t.Errorf("unexpected filter 1: %+v", q.Filters[1])
|
||||
}
|
||||
if q.OrderBy != "name" || !q.Desc {
|
||||
t.Errorf("expected order by name desc, got %q desc=%v", q.OrderBy, q.Desc)
|
||||
}
|
||||
if q.Limit != 10 {
|
||||
t.Errorf("expected limit 10, got %d", q.Limit)
|
||||
}
|
||||
if q.Offset != 5 {
|
||||
t.Errorf("expected offset 5, got %d", q.Offset)
|
||||
}
|
||||
}
|
||||
+22
-63
@@ -1,77 +1,36 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
// Option configures a Database.
|
||||
type Option func(*DatabaseOptions)
|
||||
|
||||
// Options for model configuration
|
||||
type Options struct {
|
||||
// Context for the model
|
||||
Context context.Context
|
||||
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
|
||||
Model string
|
||||
// APIKey for authentication
|
||||
APIKey string
|
||||
// BaseURL for the API endpoint
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
// DatabaseOptions holds configuration for a Database backend.
|
||||
type DatabaseOptions struct {
|
||||
// DSN is the data source name / connection string.
|
||||
DSN string
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
type GenerateOptions struct {
|
||||
// Context for this specific generate call
|
||||
Context context.Context
|
||||
}
|
||||
|
||||
// Option is a function that modifies Options
|
||||
type Option func(*Options)
|
||||
|
||||
// GenerateOption is a function that modifies GenerateOptions
|
||||
type GenerateOption func(*GenerateOptions)
|
||||
|
||||
// NewOptions creates new Options with defaults
|
||||
func NewOptions(opts ...Option) Options {
|
||||
options := Options{
|
||||
Context: context.Background(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// WithModel sets the model name
|
||||
func WithModel(m string) Option {
|
||||
return func(o *Options) {
|
||||
o.Model = m
|
||||
// WithDSN sets the data source name for the database connection.
|
||||
func WithDSN(dsn string) Option {
|
||||
return func(o *DatabaseOptions) {
|
||||
o.DSN = dsn
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key
|
||||
func WithAPIKey(key string) Option {
|
||||
return func(o *Options) {
|
||||
o.APIKey = key
|
||||
// NewDatabaseOptions creates DatabaseOptions with defaults applied.
|
||||
func NewDatabaseOptions(opts ...Option) DatabaseOptions {
|
||||
o := DatabaseOptions{}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *Options) {
|
||||
o.BaseURL = url
|
||||
}
|
||||
}
|
||||
// ModelOption configures a Model instance.
|
||||
type ModelOption func(*Schema)
|
||||
|
||||
// WithContext sets the context
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolHandler sets the tool handler
|
||||
func WithToolHandler(handler ToolHandler) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolHandler = handler
|
||||
// WithTable overrides the auto-derived table name.
|
||||
func WithTable(name string) ModelOption {
|
||||
return func(s *Schema) {
|
||||
s.Table = name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Package postgres provides a PostgreSQL Database implementation for the model package.
|
||||
// Uses lib/pq driver. Best for production deployments with rich query support.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Database is a PostgreSQL model.Database implementation.
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New creates a new Postgres database. DSN is a connection string
|
||||
// (e.g., "postgres://user:pass@localhost/dbname?sslmode=disable").
|
||||
func New(dsn string) *Database {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("model/postgres: failed to open: %v", err))
|
||||
}
|
||||
return &Database{db: db}
|
||||
}
|
||||
|
||||
func (d *Database) Init(opts ...model.Option) error {
|
||||
return d.db.Ping()
|
||||
}
|
||||
|
||||
func (d *Database) NewTable(schema *model.Schema) error {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
colType := goTypeToPostgres(f.Type)
|
||||
col := fmt.Sprintf("%s %s", quoteIdent(f.Column), colType)
|
||||
if f.IsKey {
|
||||
col += " PRIMARY KEY"
|
||||
}
|
||||
cols = append(cols, col)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", quoteIdent(schema.Table), strings.Join(cols, ", "))
|
||||
if _, err := d.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("model/postgres: create table: %w", err)
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
for _, f := range schema.Fields {
|
||||
if f.Index && !f.IsKey {
|
||||
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
|
||||
quoteIdent("idx_"+schema.Table+"_"+f.Column),
|
||||
quoteIdent(schema.Table),
|
||||
quoteIdent(f.Column))
|
||||
if _, err := d.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("model/postgres: create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
cols, placeholders, values := buildInsert(schema, fields)
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", quoteIdent(schema.Table), cols, placeholders)
|
||||
_, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
|
||||
return model.ErrDuplicateKey
|
||||
}
|
||||
return fmt.Errorf("model/postgres: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
|
||||
cols := columnList(schema)
|
||||
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = $1", cols, quoteIdent(schema.Table), quoteIdent(schema.Key))
|
||||
row := d.db.QueryRowContext(ctx, query, key)
|
||||
return scanRow(schema, row)
|
||||
}
|
||||
|
||||
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
setClauses, values := buildUpdate(schema, fields)
|
||||
values = append(values, key)
|
||||
paramIdx := len(values)
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = $%d",
|
||||
quoteIdent(schema.Table), setClauses, quoteIdent(schema.Key), paramIdx)
|
||||
result, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/postgres: update: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s = $1", quoteIdent(schema.Table), quoteIdent(schema.Key))
|
||||
result, err := d.db.ExecContext(ctx, query, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/postgres: delete: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
cols := columnList(schema)
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM %s", cols, quoteIdent(schema.Table))
|
||||
var args []any
|
||||
paramN := 1
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs, nextParam := buildWhere(q.Filters, paramN)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
paramN = nextParam
|
||||
}
|
||||
|
||||
if q.OrderBy != "" {
|
||||
dir := "ASC"
|
||||
if q.Desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY %s %s", quoteIdent(q.OrderBy), dir)
|
||||
}
|
||||
|
||||
if q.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", q.Limit)
|
||||
}
|
||||
if q.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", q.Offset)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model/postgres: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanRows(schema, rows)
|
||||
}
|
||||
|
||||
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", quoteIdent(schema.Table))
|
||||
var args []any
|
||||
paramN := 1
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs, _ := buildWhere(q.Filters, paramN)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := d.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model/postgres: count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func (d *Database) String() string {
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
// SQL helpers
|
||||
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func goTypeToPostgres(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.Int, reflect.Int64:
|
||||
return "BIGINT"
|
||||
case reflect.Int8, reflect.Int16, reflect.Int32:
|
||||
return "INTEGER"
|
||||
case reflect.Uint, reflect.Uint64:
|
||||
return "BIGINT"
|
||||
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||
return "INTEGER"
|
||||
case reflect.Float32:
|
||||
return "REAL"
|
||||
case reflect.Float64:
|
||||
return "DOUBLE PRECISION"
|
||||
case reflect.Bool:
|
||||
return "BOOLEAN"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var values []any
|
||||
i := 1
|
||||
for _, f := range schema.Fields {
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
cols = append(cols, quoteIdent(f.Column))
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
|
||||
values = append(values, v)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
|
||||
}
|
||||
|
||||
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
|
||||
var setClauses []string
|
||||
var values []any
|
||||
i := 1
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
continue
|
||||
}
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", quoteIdent(f.Column), i))
|
||||
values = append(values, v)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return strings.Join(setClauses, ", "), values
|
||||
}
|
||||
|
||||
func buildWhere(filters []model.Filter, startParam int) (string, []any, int) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
n := startParam
|
||||
for _, f := range filters {
|
||||
clauses = append(clauses, fmt.Sprintf("%s %s $%d", quoteIdent(f.Field), f.Op, n))
|
||||
args = append(args, f.Value)
|
||||
n++
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args, n
|
||||
}
|
||||
|
||||
func columnList(schema *model.Schema) string {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
cols = append(cols, quoteIdent(f.Column))
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := row.Scan(ptrs...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("model/postgres: scan: %w", err)
|
||||
}
|
||||
result := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
|
||||
var results []map[string]any
|
||||
for rows.Next() {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("model/postgres: scan row: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func newScanPtr(t reflect.Type) any {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return new(string)
|
||||
case reflect.Int, reflect.Int64:
|
||||
return new(int64)
|
||||
case reflect.Int32:
|
||||
return new(int32)
|
||||
case reflect.Float64:
|
||||
return new(float64)
|
||||
case reflect.Float32:
|
||||
return new(float32)
|
||||
case reflect.Bool:
|
||||
return new(bool)
|
||||
default:
|
||||
return new(string)
|
||||
}
|
||||
}
|
||||
|
||||
func derefScanPtr(ptr any, t reflect.Type) any {
|
||||
rv := reflect.ValueOf(ptr).Elem()
|
||||
if rv.Type().ConvertibleTo(t) {
|
||||
return rv.Convert(t).Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
// QueryOptions configures a List or Count operation.
|
||||
type QueryOptions struct {
|
||||
Filters []Filter
|
||||
OrderBy string
|
||||
Desc bool
|
||||
Limit uint
|
||||
Offset uint
|
||||
}
|
||||
|
||||
// Filter represents a field-level query condition.
|
||||
type Filter struct {
|
||||
Field string // Column name
|
||||
Op string // Operator: =, !=, <, >, <=, >=, LIKE
|
||||
Value any // Comparison value
|
||||
}
|
||||
|
||||
// QueryOption sets values in QueryOptions.
|
||||
type QueryOption func(*QueryOptions)
|
||||
|
||||
// ApplyQueryOptions applies a set of QueryOptions and returns the result.
|
||||
func ApplyQueryOptions(opts ...QueryOption) QueryOptions {
|
||||
q := QueryOptions{}
|
||||
for _, o := range opts {
|
||||
o(&q)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// Where adds an equality filter: field = value.
|
||||
func Where(field string, value any) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Filters = append(q.Filters, Filter{Field: field, Op: "=", Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// WhereOp adds a filter with a custom operator (=, !=, <, >, <=, >=, LIKE).
|
||||
func WhereOp(field, op string, value any) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Filters = append(q.Filters, Filter{Field: field, Op: op, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// OrderAsc orders results by field ascending.
|
||||
func OrderAsc(field string) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.OrderBy = field
|
||||
q.Desc = false
|
||||
}
|
||||
}
|
||||
|
||||
// OrderDesc orders results by field descending.
|
||||
func OrderDesc(field string) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.OrderBy = field
|
||||
q.Desc = true
|
||||
}
|
||||
}
|
||||
|
||||
// Limit limits the number of returned records.
|
||||
func Limit(n uint) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// Offset skips the first n records (for pagination).
|
||||
func Offset(n uint) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Offset = n
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package sqlite provides a SQLite Database implementation for the model package.
|
||||
// Uses mattn/go-sqlite3 for broad compatibility.
|
||||
// Good for development, testing, and single-node production.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Database is a SQLite model.Database implementation.
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New creates a new SQLite database. DSN is the file path (e.g., "data.db" or ":memory:").
|
||||
func New(dsn string) *Database {
|
||||
if dsn == "" {
|
||||
dsn = ":memory:"
|
||||
}
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("model/sqlite: failed to open %q: %v", dsn, err))
|
||||
}
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
db.Exec("PRAGMA journal_mode=WAL")
|
||||
return &Database{db: db}
|
||||
}
|
||||
|
||||
func (d *Database) Init(opts ...model.Option) error {
|
||||
return d.db.Ping()
|
||||
}
|
||||
|
||||
func (d *Database) NewTable(schema *model.Schema) error {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
colType := goTypeToSQLite(f.Type)
|
||||
col := fmt.Sprintf("%q %s", f.Column, colType)
|
||||
if f.IsKey {
|
||||
col += " PRIMARY KEY"
|
||||
}
|
||||
cols = append(cols, col)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (%s)", schema.Table, strings.Join(cols, ", "))
|
||||
if _, err := d.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("model/sqlite: create table: %w", err)
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
for _, f := range schema.Fields {
|
||||
if f.Index && !f.IsKey {
|
||||
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %q ON %q (%q)",
|
||||
"idx_"+schema.Table+"_"+f.Column, schema.Table, f.Column)
|
||||
if _, err := d.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("model/sqlite: create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Create(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
cols, placeholders, values := buildInsert(schema, fields)
|
||||
query := fmt.Sprintf("INSERT INTO %q (%s) VALUES (%s)", schema.Table, cols, placeholders)
|
||||
_, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") {
|
||||
return model.ErrDuplicateKey
|
||||
}
|
||||
return fmt.Errorf("model/sqlite: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Read(ctx context.Context, schema *model.Schema, key string) (map[string]any, error) {
|
||||
cols := columnList(schema)
|
||||
query := fmt.Sprintf("SELECT %s FROM %q WHERE %q = ?", cols, schema.Table, schema.Key)
|
||||
row := d.db.QueryRowContext(ctx, query, key)
|
||||
return scanRow(schema, row)
|
||||
}
|
||||
|
||||
func (d *Database) Update(ctx context.Context, schema *model.Schema, key string, fields map[string]any) error {
|
||||
setClauses, values := buildUpdate(schema, fields)
|
||||
values = append(values, key)
|
||||
query := fmt.Sprintf("UPDATE %q SET %s WHERE %q = ?", schema.Table, setClauses, schema.Key)
|
||||
result, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/sqlite: update: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Delete(ctx context.Context, schema *model.Schema, key string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %q WHERE %q = ?", schema.Table, schema.Key)
|
||||
result, err := d.db.ExecContext(ctx, query, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/sqlite: delete: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) List(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) ([]map[string]any, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
cols := columnList(schema)
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM %q", cols, schema.Table)
|
||||
var args []any
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs := buildWhere(q.Filters)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
if q.OrderBy != "" {
|
||||
dir := "ASC"
|
||||
if q.Desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY %q %s", q.OrderBy, dir)
|
||||
}
|
||||
|
||||
if q.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", q.Limit)
|
||||
}
|
||||
if q.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", q.Offset)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model/sqlite: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanRows(schema, rows)
|
||||
}
|
||||
|
||||
func (d *Database) Count(ctx context.Context, schema *model.Schema, opts ...model.QueryOption) (int64, error) {
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %q", schema.Table)
|
||||
var args []any
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs := buildWhere(q.Filters)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := d.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model/sqlite: count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func (d *Database) String() string {
|
||||
return "sqlite"
|
||||
}
|
||||
|
||||
// SQL helpers
|
||||
|
||||
func goTypeToSQLite(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return "INTEGER"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "REAL"
|
||||
case reflect.Bool:
|
||||
return "INTEGER"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var values []any
|
||||
for _, f := range schema.Fields {
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
cols = append(cols, fmt.Sprintf("%q", f.Column))
|
||||
placeholders = append(placeholders, "?")
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
|
||||
}
|
||||
|
||||
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
|
||||
var setClauses []string
|
||||
var values []any
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
continue
|
||||
}
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%q = ?", f.Column))
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(setClauses, ", "), values
|
||||
}
|
||||
|
||||
func buildWhere(filters []model.Filter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
for _, f := range filters {
|
||||
clauses = append(clauses, fmt.Sprintf("%q %s ?", f.Field, f.Op))
|
||||
args = append(args, f.Value)
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func columnList(schema *model.Schema) string {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
cols = append(cols, fmt.Sprintf("%q", f.Column))
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := row.Scan(ptrs...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("model/sqlite: scan: %w", err)
|
||||
}
|
||||
result := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
|
||||
var results []map[string]any
|
||||
for rows.Next() {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("model/sqlite: scan row: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// newScanPtr returns a pointer suitable for sql.Scan based on the Go type.
|
||||
func newScanPtr(t reflect.Type) any {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return new(string)
|
||||
case reflect.Int, reflect.Int64:
|
||||
return new(int64)
|
||||
case reflect.Int32:
|
||||
return new(int32)
|
||||
case reflect.Float64:
|
||||
return new(float64)
|
||||
case reflect.Float32:
|
||||
return new(float32)
|
||||
case reflect.Bool:
|
||||
return new(bool)
|
||||
default:
|
||||
return new(string)
|
||||
}
|
||||
}
|
||||
|
||||
// derefScanPtr extracts the scanned value and converts to the target Go type.
|
||||
func derefScanPtr(ptr any, t reflect.Type) any {
|
||||
rv := reflect.ValueOf(ptr).Elem()
|
||||
if rv.Type().ConvertibleTo(t) {
|
||||
return rv.Convert(t).Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func setup(t *testing.T) *model.Model[User] {
|
||||
t.Helper()
|
||||
db := New(":memory:")
|
||||
return model.New[User](db)
|
||||
}
|
||||
|
||||
func TestCRUD(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
err := users.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
u, err := users.Read(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if u.Name != "Alice" {
|
||||
t.Errorf("expected Alice, got %s", u.Name)
|
||||
}
|
||||
if u.Age != 30 {
|
||||
t.Errorf("expected age 30, got %d", u.Age)
|
||||
}
|
||||
|
||||
// Update
|
||||
u.Name = "Alice Updated"
|
||||
u.Age = 31
|
||||
err = users.Update(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
u2, _ := users.Read(ctx, "1")
|
||||
if u2.Name != "Alice Updated" {
|
||||
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
|
||||
}
|
||||
if u2.Age != 31 {
|
||||
t.Errorf("expected age 31, got %d", u2.Age)
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = users.Delete(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
_, err = users.Read(ctx, "1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKey(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
err := users.Create(ctx, &User{ID: "1", Name: "Bob"})
|
||||
if err != model.ErrDuplicateKey {
|
||||
t.Errorf("expected ErrDuplicateKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := users.Read(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
err = users.Update(ctx, &User{ID: "nonexistent"})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on update, got %v", err)
|
||||
}
|
||||
|
||||
err = users.Delete(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithFilter(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
results, err := users.List(ctx, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 Alices, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithOrder(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Charlie", Age: 35})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Bob", Age: 25})
|
||||
|
||||
results, err := users.List(ctx, model.OrderAsc("name"))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "Alice" {
|
||||
t.Errorf("expected Alice first, got %s", results[0].Name)
|
||||
}
|
||||
if results[2].Name != "Charlie" {
|
||||
t.Errorf("expected Charlie last, got %s", results[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithLimitOffset(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
|
||||
users.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
|
||||
users.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
|
||||
users.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
|
||||
|
||||
results, err := users.List(ctx,
|
||||
model.OrderAsc("name"),
|
||||
model.Limit(2),
|
||||
model.Offset(1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "B" {
|
||||
t.Errorf("expected B, got %s", results[0].Name)
|
||||
}
|
||||
if results[1].Name != "C" {
|
||||
t.Errorf("expected C, got %s", results[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCount(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
count, err := users.Count(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
|
||||
count, err = users.Count(ctx, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("count with filter: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereOp(t *testing.T) {
|
||||
users := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
users.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
users.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
users.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
results, err := users.List(ctx, model.WhereOp("age", ">", 28))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 (age > 28), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema(t *testing.T) {
|
||||
users := setup(t)
|
||||
schema := users.Schema()
|
||||
|
||||
if schema.Table != "users" {
|
||||
t.Errorf("expected table 'users', got %q", schema.Table)
|
||||
}
|
||||
if schema.Key != "id" {
|
||||
t.Errorf("expected key 'id', got %q", schema.Key)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var HandleSignal = service.HandleSignal
|
||||
var Profile = service.Profile
|
||||
var Server = service.Server
|
||||
var Store = service.Store
|
||||
var Model = service.Model
|
||||
var Registry = service.Registry
|
||||
var Tracer = service.Tracer
|
||||
var Auth = service.Auth
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"go-micro.dev/v5/debug/profile"
|
||||
"go-micro.dev/v5/debug/trace"
|
||||
"go-micro.dev/v5/logger"
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/model/memory"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/selector"
|
||||
"go-micro.dev/v5/server"
|
||||
@@ -30,6 +32,7 @@ type Options struct {
|
||||
Config config.Config
|
||||
Client client.Client
|
||||
Server server.Server
|
||||
Model model.Database
|
||||
|
||||
// Other options for implementations of the interface
|
||||
// can be stored in a context
|
||||
@@ -63,6 +66,7 @@ func newOptions(opts ...Option) Options {
|
||||
Client: client.NewClient(),
|
||||
Server: server.NewRPCServer(),
|
||||
Store: store.NewStore(),
|
||||
Model: memory.New(),
|
||||
Cache: cache.NewCache(),
|
||||
Registry: registry.DefaultRegistry,
|
||||
Transport: transport.DefaultTransport,
|
||||
@@ -154,6 +158,13 @@ func Store(s store.Store) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Model sets the model database to use.
|
||||
func Model(db model.Database) Option {
|
||||
return func(o *Options) {
|
||||
o.Model = db
|
||||
}
|
||||
}
|
||||
|
||||
// Registry sets the registry for the service
|
||||
// and the underlying components.
|
||||
func Registry(r registry.Registry) Option {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
log "go-micro.dev/v5/logger"
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/server"
|
||||
"go-micro.dev/v5/store"
|
||||
signalutil "go-micro.dev/v5/util/signal"
|
||||
@@ -28,6 +29,8 @@ type Service interface {
|
||||
Client() client.Client
|
||||
// Server returns the RPC server.
|
||||
Server() server.Server
|
||||
// Model returns the data model database.
|
||||
Model() model.Database
|
||||
// Start the service (non-blocking).
|
||||
Start() error
|
||||
// Stop the service.
|
||||
@@ -105,6 +108,11 @@ func (s *serviceImpl) Server() server.Server {
|
||||
return s.opts.Server
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Model() model.Database {
|
||||
return s.opts.Model
|
||||
}
|
||||
|
||||
|
||||
func (s *serviceImpl) String() string {
|
||||
return "micro"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user