Skip to content

Commit 6b8773a

Browse files
committed
use EngineService over process.Runner
1 parent 7b46024 commit 6b8773a

File tree

6 files changed

+110
-87
lines changed

6 files changed

+110
-87
lines changed

docs/guides/engine-plugins.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ sql:
6363
| Field | Description |
6464
|-------|-------------|
6565
| `name` | Engine name used in `sql[].engine` |
66-
| `process.cmd` | Command to run: executable path and optional arguments (e.g. `sqlc-engine-external-db --dont-open-wildcard-star`). First token is the executable; remaining tokens are passed as arguments before the RPC method. |
66+
| `process.cmd` | Command to run: executable path and optional arguments (e.g. `sqlc-engine-external-db --dont-open-wildcard-star`). First token is the executable; remaining tokens are passed as arguments **before** the full RPC method name `/engine.EngineService/Parse`. |
6767
| `env` | Environment variable names passed to the plugin |
6868

6969
Each engine must define either `process` (with `cmd`) or `wasm` (with `url` and `sha256`). See [Configuration reference](../reference/config.md) for the full `engines` schema.
@@ -72,7 +72,7 @@ Each engine must define either `process` (with `cmd`) or `wasm` (with `url` and
7272

7373
For an engine with `process.cmd`, sqlc resolves and runs the plugin as follows:
7474

75-
1. **Command parsing** — `process.cmd` is split on whitespace. The first token is the executable; any further tokens are passed as arguments, and sqlc appends the RPC method name (`parse`) when invoking the plugin.
75+
1. **Command parsing** — `process.cmd` is split on whitespace. The first token is the executable; any further tokens are passed as arguments, and sqlc appends the **gRPC full method name** for Parse — the same pattern as for [codegen plugins](plugins.md): `/engine.EngineService/Parse` (the generated client passes this string as the last argv token to the child process).
7676

7777
2. **Executable lookup** — The first token is resolved the same way as in the shell:
7878
- If it contains a path separator (e.g. `/usr/bin/sqlc-engine-external-db` or `./bin/sqlc-engine-external-db`), it is treated as a path. Absolute paths are used as-is; relative paths are taken relative to the **current working directory of the process running sqlc**.
@@ -178,19 +178,22 @@ go build -o sqlc-engine-external-db .
178178

179179
## Protocol
180180

181-
Process plugins use Protocol Buffers on stdin/stdout:
181+
Process plugins use Protocol Buffers on stdin/stdout — **no TCP gRPC**, but the same **unary RPC shape** as codegen plugins: sqlc uses the generated `EngineServiceClient` with `process.Runner` implementing `grpc.ClientConnInterface`, so the child receives the **full gRPC method path** as the last command-line argument (like `/plugin.CodegenService/Generate` for codegen).
182182

183183
```
184184
sqlc → stdin (protobuf) → plugin → stdout (protobuf) → sqlc
185185
```
186186

187-
Invocation:
187+
Invocation (method name is one argv token, often quoted in shell examples):
188188

189189
```bash
190-
sqlc-engine-external-db parse # stdin: ParseRequest, stdout: ParseResponse
190+
sqlc-engine-external-db --flag /engine.EngineService/Parse
191+
# stdin: ParseRequest, stdout: ParseResponse
191192
```
192193

193-
The definition lives in `protos/engine/engine.proto` (generated Go in `pkg/engine`). After editing the proto, run `make proto-engine-plugin` to regenerate the Go code.
194+
The Go SDK `engine.Run` dispatches **Parse** when `os.Args[1]` is `/engine.EngineService/Parse` or the legacy shorthand `parse` (for older tooling).
195+
196+
The definition lives in `protos/engine/engine.proto` (messages **and** `service EngineService`; generated Go in `pkg/engine`, including `engine_grpc.pb.go` for the client stub). After editing the proto, run `make proto-engine-plugin` to regenerate the Go code.
194197

195198
## Example
196199

@@ -210,7 +213,7 @@ For each `sql[]` block, `sqlc generate` branches on the configured engine: built
210213
└─────────────────────────────────────────────────────────────────┘
211214
212215
sqlc sqlc-engine-external-db
213-
│──── spawn, args: ["parse"] ──────────────────────────────► │
216+
│──── spawn, argv ends with /engine.EngineService/Parse ─────► │
214217
│──── stdin: ParseRequest{sql=full query.sql, schema_sql|…} ► │
215218
│◄─── stdout: ParseResponse{statements: [stmt1, stmt2, …]} ── │
216219
```

internal/cmd/plugin_engine.go

Lines changed: 12 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,30 @@
1-
// This file runs a database-engine plugin as an external process (parse RPC over stdin/stdout).
1+
// This file runs a database-engine plugin as an external process (EngineService/Parse over stdin/stdout).
2+
// Like codegen plugins, sqlc uses the generated EngineServiceClient with process.Runner implementing
3+
// grpc.ClientConnInterface (full method name /engine.EngineService/Parse as the subprocess argv tail).
24
// It is used only by the plugin-engine generate path (runPluginQuerySet). Vet does not support plugin engines.
35

46
package cmd
57

68
import (
7-
"bytes"
89
"context"
9-
"errors"
1010
"fmt"
1111
"os"
12-
"os/exec"
1312
"path/filepath"
1413
"strings"
1514

1615
"github.com/sqlc-dev/sqlc/internal/compiler"
1716
"github.com/sqlc-dev/sqlc/internal/config"
17+
"github.com/sqlc-dev/sqlc/internal/ext/process"
1818
"github.com/sqlc-dev/sqlc/internal/metadata"
1919
"github.com/sqlc-dev/sqlc/internal/multierr"
2020
"github.com/sqlc-dev/sqlc/internal/plugin"
2121
"github.com/sqlc-dev/sqlc/internal/sql/ast"
2222
"github.com/sqlc-dev/sqlc/internal/sql/catalog"
2323
"github.com/sqlc-dev/sqlc/internal/sql/sqlpath"
24-
"google.golang.org/protobuf/proto"
2524

26-
"github.com/sqlc-dev/sqlc/internal/info"
2725
pb "github.com/sqlc-dev/sqlc/pkg/engine"
2826
)
2927

30-
// engineProcessRunner runs an engine plugin as an external process.
31-
type engineProcessRunner struct {
32-
Cmd string
33-
Dir string // Working directory for the plugin (config file directory)
34-
Env []string
35-
}
36-
37-
func newEngineProcessRunner(cmd, dir string, env []string) *engineProcessRunner {
38-
return &engineProcessRunner{Cmd: cmd, Dir: dir, Env: env}
39-
}
40-
41-
func (r *engineProcessRunner) invoke(ctx context.Context, method string, req, resp proto.Message) error {
42-
stdin, err := proto.Marshal(req)
43-
if err != nil {
44-
return fmt.Errorf("failed to encode request: %w", err)
45-
}
46-
47-
cmdParts := strings.Fields(r.Cmd)
48-
if len(cmdParts) == 0 {
49-
return fmt.Errorf("engine plugin not found: %s\n\nSee the engine plugins documentation: https://docs.sqlc.dev/en/latest/guides/engine-plugins.html", r.Cmd)
50-
}
51-
52-
path, err := exec.LookPath(cmdParts[0])
53-
if err != nil {
54-
return fmt.Errorf("engine plugin not found: %s\n\nSee the engine plugins documentation: https://docs.sqlc.dev/en/latest/guides/engine-plugins.html", r.Cmd)
55-
}
56-
57-
args := append(cmdParts[1:], method)
58-
cmd := exec.CommandContext(ctx, path, args...)
59-
cmd.Stdin = bytes.NewReader(stdin)
60-
if r.Dir != "" {
61-
cmd.Dir = r.Dir
62-
}
63-
cmd.Env = append(os.Environ(), fmt.Sprintf("SQLC_VERSION=%s", info.Version))
64-
65-
out, err := cmd.Output()
66-
if err != nil {
67-
stderr := err.Error()
68-
var exit *exec.ExitError
69-
if errors.As(err, &exit) {
70-
stderr = string(exit.Stderr)
71-
}
72-
return fmt.Errorf("engine plugin error: %s", stderr)
73-
}
74-
75-
if err := proto.Unmarshal(out, resp); err != nil {
76-
return fmt.Errorf("failed to decode response: %w", err)
77-
}
78-
return nil
79-
}
80-
81-
// parseRequest invokes the plugin's Parse RPC. Used by runPluginQuerySet.
82-
func (r *engineProcessRunner) parseRequest(ctx context.Context, req *pb.ParseRequest) (*pb.ParseResponse, error) {
83-
resp := &pb.ParseResponse{}
84-
if err := r.invoke(ctx, "parse", req, resp); err != nil {
85-
return nil, err
86-
}
87-
return resp, nil
88-
}
89-
9028
// runPluginQuerySet runs the plugin-engine path: schema and queries are sent to the
9129
// engine plugin via ParseRequest; the responses are turned into compiler.Result and
9230
// passed to ProcessResult. No AST or compiler parsing is used.
@@ -139,7 +77,13 @@ func runPluginQuerySet(ctx context.Context, rp resultProcessor, name, dir string
13977
return o.PluginParseFunc(schemaStr, querySQL)
14078
}
14179
} else {
142-
r := newEngineProcessRunner(enginePlugin.Process.Cmd, combo.Dir, enginePlugin.Env)
80+
runner := &process.Runner{
81+
Cmd: enginePlugin.Process.Cmd,
82+
Env: enginePlugin.Env,
83+
Dir: combo.Dir,
84+
InheritParentEnv: true,
85+
}
86+
engineClient := pb.NewEngineServiceClient(runner)
14387
parseFn = func(querySQL string) (*pb.ParseResponse, error) {
14488
req := &pb.ParseRequest{Sql: querySQL}
14589
if databaseOnly {
@@ -149,7 +93,7 @@ func runPluginQuerySet(ctx context.Context, rp resultProcessor, name, dir string
14993
} else {
15094
req.SchemaSource = &pb.ParseRequest_SchemaSql{SchemaSql: schemaSQL}
15195
}
152-
return r.parseRequest(ctx, req)
96+
return engineClient.Parse(ctx, req)
15397
}
15498
}
15599

internal/cmd/plugin_engine_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,11 @@ func TestPluginPipeline_FullPipeline(t *testing.T) {
232232
}
233233

234234
// TestPluginPipeline_WithoutOverride_UsesPluginPackage proves that when PluginParseFunc
235-
// is not set, the pipeline calls the engine process runner (newEngineProcessRunner + parseRequest).
235+
// is not set, the pipeline calls process.Runner + NewEngineServiceClient(...).Parse (real subprocess).
236236
// It runs generate with a plugin engine and nil PluginParseFunc; we expect failure
237237
// (e.g. from running "echo" as the engine binary), but the error must NOT be
238238
// "unknown engine" — so we know we went past config lookup and into the plugin path.
239-
// If you add panic("azaza") at the start of newEngineProcessRunner or parseRequest,
240-
// this test will panic, confirming that the plugin package is actually invoked.
239+
// If you add panic at the start of that path, this test will panic, confirming the runner is invoked.
241240
func TestPluginPipeline_WithoutOverride_UsesPluginPackage(t *testing.T) {
242241
ctx := context.Background()
243242
conf, err := config.ParseConfig(strings.NewReader(testPluginPipelineConfig))

internal/ext/process/gen.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"os"
99
"os/exec"
10+
"strings"
1011

1112
"google.golang.org/grpc"
1213
"google.golang.org/grpc/codes"
@@ -22,6 +23,12 @@ type Runner struct {
2223
Cmd string
2324
Format string
2425
Env []string
26+
// Dir, if set, is the working directory for the child process (e.g. directory of sqlc.yaml).
27+
Dir string
28+
// InheritParentEnv, if true, starts the child with os.Environ() and appends SQLC_VERSION and
29+
// variables listed in Env (same names as codegen). Use for database engine plugins that need
30+
// a normal shell-like environment (e.g. PATH). Default false matches historical codegen behavior.
31+
InheritParentEnv bool
2532
}
2633

2734
func (r *Runner) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error {
@@ -53,16 +60,25 @@ func (r *Runner) Invoke(ctx context.Context, method string, args any, reply any,
5360
return fmt.Errorf("unknown plugin format: %s", r.Format)
5461
}
5562

56-
// Check if the output plugin exists
57-
path, err := exec.LookPath(r.Cmd)
63+
cmdFields := strings.Fields(strings.TrimSpace(r.Cmd))
64+
if len(cmdFields) == 0 {
65+
return fmt.Errorf("process: empty command")
66+
}
67+
exePath, err := exec.LookPath(cmdFields[0])
5868
if err != nil {
5969
return fmt.Errorf("process: %s not found", r.Cmd)
6070
}
6171

62-
cmd := exec.CommandContext(ctx, path, method)
72+
argv := append(append([]string(nil), cmdFields[1:]...), method)
73+
cmd := exec.CommandContext(ctx, exePath, argv...)
6374
cmd.Stdin = bytes.NewReader(stdin)
64-
cmd.Env = []string{
65-
fmt.Sprintf("SQLC_VERSION=%s", info.Version),
75+
if r.Dir != "" {
76+
cmd.Dir = r.Dir
77+
}
78+
if r.InheritParentEnv {
79+
cmd.Env = append(append([]string(nil), os.Environ()...), fmt.Sprintf("SQLC_VERSION=%s", info.Version))
80+
} else {
81+
cmd.Env = []string{fmt.Sprintf("SQLC_VERSION=%s", info.Version)}
6682
}
6783
for _, key := range r.Env {
6884
if key == "SQLC_AUTH_TOKEN" {

pkg/engine/sdk.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func Run(h Handler) {
7070

7171
func run(h Handler, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
7272
if len(args) < 2 {
73-
return fmt.Errorf("usage: %s <method>", args[0])
73+
return fmt.Errorf("usage: %s <%s|parse>", args[0], EngineService_Parse_FullMethodName)
7474
}
7575

7676
method := args[1]
@@ -81,8 +81,12 @@ func run(h Handler, args []string, stdin io.Reader, stdout, stderr io.Writer) er
8181

8282
var output proto.Message
8383

84-
switch method {
85-
case "parse":
84+
switch {
85+
// Full gRPC method name (same argv tail as codegen plugins use for CodegenService/Generate).
86+
case method == EngineService_Parse_FullMethodName:
87+
fallthrough
88+
// Legacy shorthand for manual invocation and older sqlc versions.
89+
case method == "parse":
8690
var req ParseRequest
8791
if err := proto.Unmarshal(input, &req); err != nil {
8892
return fmt.Errorf("parsing request: %w", err)
@@ -93,7 +97,7 @@ func run(h Handler, args []string, stdin io.Reader, stdout, stderr io.Writer) er
9397
output, err = h.Parse(&req)
9498

9599
default:
96-
return fmt.Errorf("unknown method: %s", method)
100+
return fmt.Errorf("unknown method: %q (expected %q or legacy \"parse\")", method, EngineService_Parse_FullMethodName)
97101
}
98102

99103
if err != nil {

pkg/engine/sdk_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package engine
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"testing"
7+
8+
"google.golang.org/protobuf/proto"
9+
)
10+
11+
func TestRun_acceptsFullGRPCMethodName(t *testing.T) {
12+
called := false
13+
h := Handler{
14+
Parse: func(req *ParseRequest) (*ParseResponse, error) {
15+
called = true
16+
return &ParseResponse{}, nil
17+
},
18+
}
19+
in, err := proto.Marshal(&ParseRequest{Sql: "SELECT 1"})
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
var stdout bytes.Buffer
24+
err = run(h, []string{"plugin", EngineService_Parse_FullMethodName}, bytes.NewReader(in), &stdout, io.Discard)
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
if !called {
29+
t.Fatal("Parse not invoked for full gRPC method argv")
30+
}
31+
var resp ParseResponse
32+
if err := proto.Unmarshal(stdout.Bytes(), &resp); err != nil {
33+
t.Fatalf("stdout protobuf: %v", err)
34+
}
35+
}
36+
37+
func TestRun_acceptsLegacyParseArgv(t *testing.T) {
38+
called := false
39+
h := Handler{
40+
Parse: func(req *ParseRequest) (*ParseResponse, error) {
41+
called = true
42+
return &ParseResponse{}, nil
43+
},
44+
}
45+
in, err := proto.Marshal(&ParseRequest{})
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
var stdout bytes.Buffer
50+
err = run(h, []string{"plugin", "parse"}, bytes.NewReader(in), &stdout, io.Discard)
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
if !called {
55+
t.Fatal("Parse not invoked for legacy parse argv")
56+
}
57+
}

0 commit comments

Comments
 (0)