Skip to content

Commit eda87b8

Browse files
committed
Introduce internal/api package with Generate entry point
The new package mirrors esbuild's Build API: a single api.Generate(ctx, api.GenerateOptions{}) call returns a GenerateResult containing the generated files and any errors. Most of cmd/generate.go's logic moves here as unexported helpers; the only exported names are Generate, GenerateOptions, and GenerateResult. cmd.Generate is now a thin wrapper that translates the CLI's Options struct into api.GenerateOptions. The endtoend tests call api.Generate directly for TestExamples, TestReplay (generate command), and the benchmarks. https://claude.ai/code/session_01RCzB2JR5Y5ScFDUmwcxGVZ
1 parent 977ac6d commit eda87b8

9 files changed

Lines changed: 833 additions & 210 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/config.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/sqlc-dev/sqlc/internal/config"
11+
)
12+
13+
const errMessageNoVersion = `The configuration file must have a version number.
14+
Set the version to 1 or 2 at the top of sqlc.json:
15+
16+
{
17+
"version": "1"
18+
...
19+
}
20+
`
21+
22+
const errMessageUnknownVersion = `The configuration file has an invalid version number.
23+
The supported version can only be "1" or "2".
24+
`
25+
26+
const errMessageNoPackages = `No packages are configured`
27+
28+
func readConfig(stderr io.Writer, dir, filename string) (string, *config.Config, error) {
29+
configPath := ""
30+
if filename != "" {
31+
configPath = filepath.Join(dir, filename)
32+
} else {
33+
var yamlMissing, jsonMissing, ymlMissing bool
34+
yamlPath := filepath.Join(dir, "sqlc.yaml")
35+
ymlPath := filepath.Join(dir, "sqlc.yml")
36+
jsonPath := filepath.Join(dir, "sqlc.json")
37+
38+
if _, err := os.Stat(yamlPath); os.IsNotExist(err) {
39+
yamlMissing = true
40+
}
41+
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
42+
jsonMissing = true
43+
}
44+
45+
if _, err := os.Stat(ymlPath); os.IsNotExist(err) {
46+
ymlMissing = true
47+
}
48+
49+
if yamlMissing && ymlMissing && jsonMissing {
50+
fmt.Fprintln(stderr, "error parsing configuration files. sqlc.(yaml|yml) or sqlc.json: file does not exist")
51+
return "", nil, errors.New("config file missing")
52+
}
53+
54+
if (!yamlMissing || !ymlMissing) && !jsonMissing {
55+
fmt.Fprintln(stderr, "error: both sqlc.json and sqlc.(yaml|yml) files present")
56+
return "", nil, errors.New("sqlc.json and sqlc.(yaml|yml) present")
57+
}
58+
59+
if jsonMissing {
60+
if yamlMissing {
61+
configPath = ymlPath
62+
} else {
63+
configPath = yamlPath
64+
}
65+
} else {
66+
configPath = jsonPath
67+
}
68+
}
69+
70+
base := filepath.Base(configPath)
71+
file, err := os.Open(configPath)
72+
if err != nil {
73+
fmt.Fprintf(stderr, "error parsing %s: file does not exist\n", base)
74+
return "", nil, err
75+
}
76+
defer file.Close()
77+
78+
conf, err := config.ParseConfig(file)
79+
if err != nil {
80+
switch err {
81+
case config.ErrMissingVersion:
82+
fmt.Fprint(stderr, errMessageNoVersion)
83+
case config.ErrUnknownVersion:
84+
fmt.Fprint(stderr, errMessageUnknownVersion)
85+
case config.ErrNoPackages:
86+
fmt.Fprint(stderr, errMessageNoPackages)
87+
}
88+
fmt.Fprintf(stderr, "error parsing %s: %s\n", base, err)
89+
return "", nil, err
90+
}
91+
92+
return configPath, &conf, nil
93+
}

internal/api/generate.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"path/filepath"
9+
"strings"
10+
"sync"
11+
12+
"github.com/sqlc-dev/sqlc/internal/compiler"
13+
"github.com/sqlc-dev/sqlc/internal/config"
14+
)
15+
16+
// errPluginProcessDisabled is returned when the configuration uses a process
17+
// plugin but the caller has disabled them via GenerateOptions.DisableProcessPlugins.
18+
var errPluginProcessDisabled = errors.New("plugin: process-based plugins disabled via SQLCDEBUG=processplugins=0")
19+
20+
// GenerateOptions controls a single Generate invocation.
21+
type GenerateOptions struct {
22+
// Dir is the working directory used to resolve the config file and any
23+
// relative schema/query paths within it.
24+
Dir string
25+
26+
// File is the configuration filename to use, relative to Dir. When empty,
27+
// Generate looks for sqlc.yaml, sqlc.yml, or sqlc.json in Dir.
28+
File string
29+
30+
// Stderr receives diagnostic output. If nil, output is discarded.
31+
Stderr io.Writer
32+
33+
// DisableProcessPlugins, when true, causes Generate to fail if the
34+
// configuration uses a process-based plugin. The sqlc CLI sets this from
35+
// SQLCDEBUG=processplugins=0.
36+
DisableProcessPlugins bool
37+
38+
// MutateConfig is called after the configuration is parsed but before it is
39+
// validated. It is intended for tests.
40+
MutateConfig func(*config.Config)
41+
}
42+
43+
// GenerateResult is the outcome of a Generate call. Files maps absolute output
44+
// paths to file contents; callers are responsible for writing them to disk if
45+
// desired. Errors collects any errors encountered during code generation.
46+
type GenerateResult struct {
47+
// Files maps absolute output paths to generated file contents.
48+
Files map[string]string
49+
50+
// Errors collects any errors encountered. A non-empty Errors slice means
51+
// generation did not fully succeed.
52+
Errors []error
53+
}
54+
55+
// Generate parses the sqlc configuration referenced by opts and runs every
56+
// configured codegen target. The returned GenerateResult always has a non-nil
57+
// Files map; the map is empty when generation fails before any files are
58+
// produced.
59+
func Generate(ctx context.Context, opts GenerateOptions) GenerateResult {
60+
stderr := opts.Stderr
61+
if stderr == nil {
62+
stderr = io.Discard
63+
}
64+
65+
res := GenerateResult{Files: map[string]string{}}
66+
67+
configPath, conf, err := readConfig(stderr, opts.Dir, opts.File)
68+
if err != nil {
69+
res.Errors = append(res.Errors, err)
70+
return res
71+
}
72+
if opts.MutateConfig != nil {
73+
opts.MutateConfig(conf)
74+
}
75+
76+
base := filepath.Base(configPath)
77+
if err := config.Validate(conf); err != nil {
78+
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
79+
res.Errors = append(res.Errors, err)
80+
return res
81+
}
82+
83+
if opts.DisableProcessPlugins {
84+
if err := validateProcessPluginsDisabled(conf); err != nil {
85+
fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
86+
res.Errors = append(res.Errors, err)
87+
return res
88+
}
89+
}
90+
91+
g := &generator{
92+
dir: opts.Dir,
93+
output: map[string]string{},
94+
}
95+
96+
if err := processQuerySets(ctx, g, conf, opts.Dir, stderr); err != nil {
97+
res.Errors = append(res.Errors, err)
98+
return res
99+
}
100+
101+
res.Files = g.output
102+
return res
103+
}
104+
105+
func validateProcessPluginsDisabled(cfg *config.Config) error {
106+
for _, plugin := range cfg.Plugins {
107+
if plugin.Process != nil {
108+
return errPluginProcessDisabled
109+
}
110+
}
111+
return nil
112+
}
113+
114+
type generator struct {
115+
m sync.Mutex
116+
dir string
117+
output map[string]string
118+
}
119+
120+
func (g *generator) Pairs(ctx context.Context, conf *config.Config) []outputPair {
121+
var pairs []outputPair
122+
for _, sql := range conf.SQL {
123+
if sql.Gen.Go != nil {
124+
pairs = append(pairs, outputPair{
125+
SQL: sql,
126+
Gen: config.SQLGen{Go: sql.Gen.Go},
127+
})
128+
}
129+
if sql.Gen.JSON != nil {
130+
pairs = append(pairs, outputPair{
131+
SQL: sql,
132+
Gen: config.SQLGen{JSON: sql.Gen.JSON},
133+
})
134+
}
135+
for i := range sql.Codegen {
136+
pairs = append(pairs, outputPair{
137+
SQL: sql,
138+
Plugin: &sql.Codegen[i],
139+
})
140+
}
141+
}
142+
return pairs
143+
}
144+
145+
func (g *generator) ProcessResult(ctx context.Context, combo config.CombinedSettings, sql outputPair, result *compiler.Result) error {
146+
out, resp, err := codegen(ctx, combo, sql, result)
147+
if err != nil {
148+
return err
149+
}
150+
files := map[string]string{}
151+
for _, file := range resp.Files {
152+
files[file.Name] = string(file.Contents)
153+
}
154+
g.m.Lock()
155+
156+
// out is specified by the user, not a plugin
157+
absout := filepath.Join(g.dir, out)
158+
159+
for n, source := range files {
160+
filename := filepath.Join(g.dir, out, n)
161+
// filepath.Join calls filepath.Clean which should remove all "..", but
162+
// double check to make sure
163+
if strings.Contains(filename, "..") {
164+
return fmt.Errorf("invalid file output path: %s", filename)
165+
}
166+
// The output file must be contained inside the output directory
167+
if !strings.HasPrefix(filename, absout) {
168+
return fmt.Errorf("invalid file output path: %s", filename)
169+
}
170+
g.output[filename] = source
171+
}
172+
g.m.Unlock()
173+
return nil
174+
}

0 commit comments

Comments
 (0)