Skip to content

Commit 8dcf3bb

Browse files
committed
Extract code generation into internal/api package
Introduce a small programmatic interface to sqlc's code generation, inspired by esbuild's Build API. The api package exports three names: func Generate(ctx, GenerateOptions) GenerateResult type GenerateOptions struct { Config io.Reader Stderr io.Writer Write bool Diff bool BaseDir string EnableProcessPlugins bool } type GenerateResult struct { Files map[string]string Errors []error } Everything else in the package — parse, codegen, the plugin shim, the result processor — is unexported. Notable design choices: * Config as io.Reader. No Dir/File fields. Callers hand sqlc whatever bytes they want; the CLI reads from disk and library callers can construct configs in memory. * BaseDir does double duty: it's the directory relative paths in the config resolve against and the prefix stripped from file paths in parse errors and diff labels. When empty it defaults to the current working directory. * Process plugins are off by default. EnableProcessPlugins is a single bool whose zero value refuses any process plugin in the config. The CLI sets it from the `processplugins` SQLCDEBUG setting (default 1). * Write/Diff replace separate functions. `sqlc generate` is api.Generate{Write: true}, `sqlc compile` is neither, `sqlc diff` is {Diff: true}. cmd.Diff is gone. CLI: each of genCmd, checkCmd, and diffCmd reads the config bytes, chdirs into the config's directory so relative paths resolve, and calls api.Generate with BaseDir set to that directory. cmd.Vet and cmd.Push keep their own helpers (parse, processQuerySets, codeGenRequest) since they have surface beyond what api covers; both packages skip joining their dir parameter when the path is already absolute, so configs with absolute paths flow through both. Endtoend tests: TestExamples and TestReplay call api.Generate directly. A mutatedConfigBytes helper parses the test's config, optionally applies a mutation (the managed-db context adds servers + sets database.managed), forces version "2" so v1 configs round-trip cleanly, and re-encodes as YAML. When mutated, the bytes are also dropped to a temp file alongside the original so cmd.Vet (which still takes a path) can use it. Per-test environment variables from exec.json are applied via t.Setenv, and cmd.Env is then populated via opts.ExperimentFromEnv — same path the CLI takes. config.AnalyzerDatabase gained MarshalYAML/MarshalJSON so the parsed Config round-trips through yaml.Marshal cleanly — needed for the test helper.
1 parent b84b1d6 commit 8dcf3bb

16 files changed

Lines changed: 1140 additions & 425 deletions

File tree

internal/api/api.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package api is intended to be the future public API for sqlc.
2+
//
3+
// The shape of this package is inspired by esbuild's Build API
4+
// (https://pkg.go.dev/github.com/evanw/esbuild/pkg/api#hdr-Build_API): a small
5+
// surface area of options structs and result structs that lets callers drive
6+
// sqlc programmatically without going through the CLI.
7+
//
8+
// Today the package lives under internal/ while the API stabilises. Once the
9+
// surface settles it is expected to graduate to pkg/api so it can be imported
10+
// by external Go programs.
11+
package api

internal/api/codegen.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"runtime/trace"
8+
9+
"google.golang.org/grpc"
10+
11+
"github.com/sqlc-dev/sqlc/internal/codegen/golang"
12+
genjson "github.com/sqlc-dev/sqlc/internal/codegen/json"
13+
"github.com/sqlc-dev/sqlc/internal/compiler"
14+
"github.com/sqlc-dev/sqlc/internal/config"
15+
"github.com/sqlc-dev/sqlc/internal/config/convert"
16+
"github.com/sqlc-dev/sqlc/internal/ext"
17+
"github.com/sqlc-dev/sqlc/internal/ext/process"
18+
"github.com/sqlc-dev/sqlc/internal/ext/wasm"
19+
"github.com/sqlc-dev/sqlc/internal/plugin"
20+
)
21+
22+
func findPlugin(conf config.Config, name string) (*config.Plugin, error) {
23+
for _, plug := range conf.Plugins {
24+
if plug.Name == name {
25+
return &plug, nil
26+
}
27+
}
28+
return nil, fmt.Errorf("plugin not found")
29+
}
30+
31+
func codegen(ctx context.Context, combo config.CombinedSettings, sql outputPair, result *compiler.Result) (string, *plugin.GenerateResponse, error) {
32+
defer trace.StartRegion(ctx, "codegen").End()
33+
req := codeGenRequest(result, combo)
34+
var handler grpc.ClientConnInterface
35+
var out string
36+
switch {
37+
case sql.Plugin != nil:
38+
out = sql.Plugin.Out
39+
plug, err := findPlugin(combo.Global, sql.Plugin.Plugin)
40+
if err != nil {
41+
return "", nil, fmt.Errorf("plugin not found: %s", err)
42+
}
43+
44+
switch {
45+
case plug.Process != nil:
46+
handler = &process.Runner{
47+
Cmd: plug.Process.Cmd,
48+
Env: plug.Env,
49+
Format: plug.Process.Format,
50+
}
51+
case plug.WASM != nil:
52+
handler = &wasm.Runner{
53+
URL: plug.WASM.URL,
54+
SHA256: plug.WASM.SHA256,
55+
Env: plug.Env,
56+
}
57+
default:
58+
return "", nil, fmt.Errorf("unsupported plugin type")
59+
}
60+
61+
opts, err := convert.YAMLtoJSON(sql.Plugin.Options)
62+
if err != nil {
63+
return "", nil, fmt.Errorf("invalid plugin options: %w", err)
64+
}
65+
req.PluginOptions = opts
66+
67+
global, found := combo.Global.Options[plug.Name]
68+
if found {
69+
opts, err := convert.YAMLtoJSON(global)
70+
if err != nil {
71+
return "", nil, fmt.Errorf("invalid global options: %w", err)
72+
}
73+
req.GlobalOptions = opts
74+
}
75+
76+
case sql.Gen.Go != nil:
77+
out = combo.Go.Out
78+
handler = ext.HandleFunc(golang.Generate)
79+
opts, err := json.Marshal(sql.Gen.Go)
80+
if err != nil {
81+
return "", nil, fmt.Errorf("opts marshal failed: %w", err)
82+
}
83+
req.PluginOptions = opts
84+
85+
if combo.Global.Overrides.Go != nil {
86+
opts, err := json.Marshal(combo.Global.Overrides.Go)
87+
if err != nil {
88+
return "", nil, fmt.Errorf("opts marshal failed: %w", err)
89+
}
90+
req.GlobalOptions = opts
91+
}
92+
93+
case sql.Gen.JSON != nil:
94+
out = combo.JSON.Out
95+
handler = ext.HandleFunc(genjson.Generate)
96+
opts, err := json.Marshal(sql.Gen.JSON)
97+
if err != nil {
98+
return "", nil, fmt.Errorf("opts marshal failed: %w", err)
99+
}
100+
req.PluginOptions = opts
101+
102+
default:
103+
return "", nil, fmt.Errorf("missing language backend")
104+
}
105+
client := plugin.NewCodegenServiceClient(handler)
106+
resp, err := client.Generate(ctx, req)
107+
return out, resp, err
108+
}

internal/api/diff.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package api
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"os"
11+
"path/filepath"
12+
"runtime/trace"
13+
"sort"
14+
15+
"github.com/cubicdaiya/gonp"
16+
)
17+
18+
func writeFiles(ctx context.Context, files map[string]string, stderr io.Writer) error {
19+
defer trace.StartRegion(ctx, "writefiles").End()
20+
for filename, source := range files {
21+
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
22+
fmt.Fprintf(stderr, "%s: %s\n", filename, err)
23+
return err
24+
}
25+
if err := os.WriteFile(filename, []byte(source), 0644); err != nil {
26+
fmt.Fprintf(stderr, "%s: %s\n", filename, err)
27+
return err
28+
}
29+
}
30+
return nil
31+
}
32+
33+
func diffFiles(ctx context.Context, baseDir string, files map[string]string, stderr io.Writer) error {
34+
defer trace.StartRegion(ctx, "checkfiles").End()
35+
var errored bool
36+
37+
if baseDir == "" {
38+
baseDir, _ = os.Getwd()
39+
}
40+
41+
keys := make([]string, 0, len(files))
42+
for k := range files {
43+
keys = append(keys, k)
44+
}
45+
sort.Strings(keys)
46+
47+
for _, filename := range keys {
48+
source := files[filename]
49+
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
50+
errored = true
51+
continue
52+
}
53+
existing, err := os.ReadFile(filename)
54+
if err != nil {
55+
errored = true
56+
fmt.Fprintf(stderr, "%s: %s\n", filename, err)
57+
continue
58+
}
59+
d := gonp.New(getLines(existing), getLines([]byte(source)))
60+
d.Compose()
61+
uniHunks := filterHunks(d.UnifiedHunks())
62+
63+
if len(uniHunks) > 0 {
64+
errored = true
65+
label := filename
66+
if baseDir != "" {
67+
if rel, err := filepath.Rel(baseDir, filename); err == nil {
68+
label = "/" + rel
69+
}
70+
}
71+
fmt.Fprintf(stderr, "--- a%s\n", label)
72+
fmt.Fprintf(stderr, "+++ b%s\n", label)
73+
d.FprintUniHunks(stderr, uniHunks)
74+
}
75+
}
76+
if errored {
77+
return errors.New("diff found")
78+
}
79+
return nil
80+
}
81+
82+
func getLines(f []byte) []string {
83+
fp := bytes.NewReader(f)
84+
scanner := bufio.NewScanner(fp)
85+
lines := make([]string, 0)
86+
for scanner.Scan() {
87+
lines = append(lines, scanner.Text())
88+
}
89+
return lines
90+
}
91+
92+
func filterHunks[T gonp.Elem](uniHunks []gonp.UniHunk[T]) []gonp.UniHunk[T] {
93+
var out []gonp.UniHunk[T]
94+
for i, uniHunk := range uniHunks {
95+
var changed bool
96+
for _, e := range uniHunk.GetChanges() {
97+
switch e.GetType() {
98+
case gonp.SesDelete:
99+
changed = true
100+
case gonp.SesAdd:
101+
changed = true
102+
}
103+
}
104+
if changed {
105+
out = append(out, uniHunks[i])
106+
}
107+
}
108+
return out
109+
}

0 commit comments

Comments
 (0)