Skip to content

Commit 5bdd7fd

Browse files
Improve package-sniffing and bind correctly to types in the same package (#316)
The original motivation here is to fix #283, which is that if you try to use `bindings` to bind to a type in the same package as the generated code, we generate a self-import, which Go doesn't allow. Fixing that is easy -- the three lines in `imports.go` -- once you know the package-path of the generated code. (The test that that all fits together is in integration-tests because that was the easiest place to set up the right situation.) Determining the package-path is not too much harder: you ask `go/packages`, which we already use for `package_bindings`. But once we're doing that, and handling errors, it's kinda silly that we ask you to specify the package your generated code will use, because we have that too, usually. So I rewrote that handling too, making `package` now rarely necessary (see for example the `example` config), and warning if it looks wrong. This is the changes in `config.go`, and is the more substantial non-test change. (I also renamed some of the testdata dirs to be valid package-names, to exercise more of that code, or in the case of `find-config`, just for consistency.) I have: - [x] Written a clear PR title and description (above) - [x] Signed the [Khan Academy CLA](https://www.khanacademy.org/r/cla) - [x] Added tests covering my changes, if applicable (and checked that the test for the bugfix fails without the rest of the changes) - [x] Included a link to the issue fixed, if applicable - [x] Included documentation, for new features - [x] Added ~an entry~ two entries to the changelog
1 parent 662ca8f commit 5bdd7fd

39 files changed

+232
-54
lines changed

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ Note that genqlient is now tested from Go 1.20 through Go 1.22.
3131
- The new `optional: generic` allows using a generic type to represent optionality. See the [documentation](genqlient.yaml) for details.
3232
- For schemas with enum values that differ only in casing, it's now possible to disable smart-casing in genqlient.yaml; see the [documentation](genqlient.yaml) for `casing` for details.
3333
- Support .graphqls and .gql file extensions
34+
- More accurately guess the package name for generated code (and warn if the config option -- now almost never needed -- looks wrong).
3435

3536
### Bug fixes:
3637
- The presence of negative pointer directives, i.e., `# @genqlient(pointer: false)` are now respected even in the when `optional: pointer` is set in the configuration file.
3738
- Made name collisions between query/mutation arguments and local function variables less likely.
3839
- Fix generation issue related to golang type implementation of complex graphql union fragments
40+
- Bind correctly to types in the same package as the generated code.
3941

4042
## v0.6.0
4143

docs/genqlient.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ operations:
2929
# genqlient.yaml. Default: generated.go.
3030
generated: generated/genqlient.go
3131

32-
# The package name for the output code; defaults to the directory name of
33-
# the generated-code file.
32+
# The package name for the output code; defaults to the package-name
33+
# corresponding to the setting of `generated`, above.
34+
#
35+
# This is rarely needed: only if you want the package-name to differ from the
36+
# suffix of the package-path, and there are no other Go files in the package
37+
# already.
3438
package: mygenerated
3539

3640
# If set, a file at this path (relative to genqlient.yaml) will be generated
@@ -139,6 +143,9 @@ optional_generic_type: github.com/organisation/repository/example.Type
139143
# guarantees that the fields requested in the query match those present in
140144
# the Go type.
141145
#
146+
# Note: if binding to types in the same package as the generated code, make
147+
# sure you don't bind to generated types! Otherwise, things get very circular.
148+
#
142149
# To get equivalent behavior in just one query, use @genqlient(bind: ...);
143150
# see genqlient_directive.graphql for more details.
144151
bindings:
@@ -224,6 +231,9 @@ bindings:
224231
# to the bindings map, above, for each exported type in the package. Multiple
225232
# packages may be specified, and later ones take precedence over earlier ones.
226233
# Explicit entries in bindings take precedence over all package bindings.
234+
#
235+
# Note: make sure this isn't the package with your generated code, or things
236+
# will get circular very fast.
227237
package_bindings:
228238
- package: github.com/you/yourpkg/models
229239

example/genqlient.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ schema: schema.graphql
22
operations:
33
- genqlient.graphql
44
generated: generated.go
5-
# needed since it doesn't match the directory name:
6-
package: main
75

86
# We bind github's DateTime scalar type to Go's time.Time (which conveniently
97
# already defines MarshalJSON and UnmarshalJSON). This means genqlient will

generate/config.go

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ type Config struct {
4747
// The directory of the config-file (relative to which all the other paths
4848
// are resolved). Set by ValidateAndFillDefaults.
4949
baseDir string
50+
// The package-path into which we are generating.
51+
pkgPath string
5052
}
5153

5254
// A TypeBinding represents a Go type to which genqlient will bind a particular
@@ -132,6 +134,52 @@ func pathJoin(a, b string) string {
132134
return filepath.Join(a, b)
133135
}
134136

137+
// Try to figure out the package-name and package-path of the given .go file.
138+
//
139+
// Returns a best-guess pkgName if possible, even on error.
140+
func getPackageNameAndPath(filename string) (pkgName, pkgPath string, err error) {
141+
abs, err := filepath.Abs(filename)
142+
if err != nil { // path is totally bogus
143+
return "", "", err
144+
}
145+
146+
dir := filepath.Dir(abs)
147+
// If we don't get a clean answer from go/packages, we'll use the
148+
// directory-name as a backup guess, as long as it's a valid identifier.
149+
pkgNameGuess := filepath.Base(dir)
150+
if !token.IsIdentifier(pkgNameGuess) {
151+
pkgNameGuess = ""
152+
}
153+
154+
pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName}, dir)
155+
if err != nil { // e.g. not in a Go module
156+
return pkgNameGuess, "", err
157+
} else if len(pkgs) != 1 { // probably never happens?
158+
return pkgNameGuess, "", fmt.Errorf("found %v packages in %v, expected 1", len(pkgs), dir)
159+
}
160+
161+
pkg := pkgs[0]
162+
// TODO(benkraft): Can PkgPath ever be empty while in a module? If so, we
163+
// could warn.
164+
if pkg.Name != "" { // found a good package!
165+
return pkg.Name, pkg.PkgPath, nil
166+
}
167+
168+
// Package path is valid, but name is empty: probably an empty package
169+
// (within a valid module). If the package-path-suffix is a valid
170+
// identifier, that's a better guess than the directory-suffix, so use it.
171+
pathSuffix := filepath.Base(pkg.PkgPath)
172+
if token.IsIdentifier(pathSuffix) {
173+
pkgNameGuess = pathSuffix
174+
}
175+
176+
if pkgNameGuess != "" {
177+
return pkgNameGuess, pkg.PkgPath, nil
178+
} else {
179+
return "", "", fmt.Errorf("no package found in %v", dir)
180+
}
181+
}
182+
135183
// ValidateAndFillDefaults ensures that the configuration is valid, and fills
136184
// in any options that were unspecified.
137185
//
@@ -167,29 +215,40 @@ func (c *Config) ValidateAndFillDefaults(baseDir string) error {
167215
"\nExample: \"github.com/Org/Repo/optional.Value\"")
168216
}
169217

170-
if c.Package != "" {
171-
if !token.IsIdentifier(c.Package) {
172-
// No need for link here -- if you're already setting the package
173-
// you know where to set the package.
174-
return errorf(nil, "invalid package in genqlient.yaml: '%v' is not a valid identifier", c.Package)
175-
}
176-
} else {
177-
abs, err := filepath.Abs(c.Generated)
178-
if err != nil {
218+
if c.Package != "" && !token.IsIdentifier(c.Package) {
219+
// No need for link here -- if you're already setting the package
220+
// you know where to set the package.
221+
return errorf(nil, "invalid package in genqlient.yaml: '%v' is not a valid identifier", c.Package)
222+
}
223+
224+
pkgName, pkgPath, err := getPackageNameAndPath(c.Generated)
225+
if err != nil {
226+
// Try to guess a name anyway (or use one you specified) -- pkgPath
227+
// isn't always needed. (But you'll run into trouble binding against
228+
// the generated package, so at least warn.)
229+
if c.Package != "" {
230+
warn(errorf(nil, "warning: unable to identify current package-path "+
231+
"(using 'package' config '%v'): %v\n", c.Package, err))
232+
} else if pkgName != "" {
233+
warn(errorf(nil, "warning: unable to identify current package-path "+
234+
"(using directory name '%v': %v\n", pkgName, err))
235+
c.Package = pkgName
236+
} else {
179237
return errorf(nil, "unable to guess package-name: %v"+
180238
"\nSet package name in genqlient.yaml"+
181239
"\nExample: https://github.com/Khan/genqlient/blob/main/example/genqlient.yaml#L6", err)
182240
}
183-
184-
base := filepath.Base(filepath.Dir(abs))
185-
if !token.IsIdentifier(base) {
186-
return errorf(nil, "unable to guess package-name: '%v' is not a valid identifier"+
187-
"\nSet package name in genqlient.yaml"+
188-
"\nExample: https://github.com/Khan/genqlient/blob/main/example/genqlient.yaml#L6", base)
241+
} else { // err == nil
242+
if c.Package == pkgName || c.Package == "" {
243+
c.Package = pkgName
244+
} else {
245+
warn(errorf(nil, "warning: package setting in genqlient.yaml '%v' looks wrong "+
246+
"('%v' is in package '%v') but proceeding with '%v' anyway\n",
247+
c.Package, c.Generated, pkgName, c.Package))
189248
}
190-
191-
c.Package = base
192249
}
250+
// This is a no-op in some of the error cases, but it still doesn't hurt.
251+
c.pkgPath = pkgPath
193252

194253
if len(c.PackageBindings) > 0 {
195254
for _, binding := range c.PackageBindings {
@@ -201,6 +260,11 @@ func (c *Config) ValidateAndFillDefaults(baseDir string) error {
201260
binding.Package)
202261
}
203262

263+
if binding.Package == c.pkgPath {
264+
warn(errorf(nil, "warning: package_bindings set to the same package as your generated "+
265+
"code ('%v'); this may cause nondeterministic output due to circularity", c.pkgPath))
266+
}
267+
204268
mode := packages.NeedDeps | packages.NeedTypes
205269
pkgs, err := packages.Load(&packages.Config{
206270
Mode: mode,

generate/config_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import (
1111
)
1212

1313
const (
14-
findConfigDir = "testdata/find-config"
15-
validConfigDir = "testdata/valid-config"
16-
invalidConfigDir = "testdata/invalid-config"
14+
findConfigDir = "testdata/findConfig"
15+
validConfigDir = "testdata/validConfig"
16+
invalidConfigDir = "testdata/invalidConfig"
1717
)
1818

1919
func TestFindCfg(t *testing.T) {

generate/imports.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func (g *generator) ref(fullyQualifiedName string) (qualifiedName string, err er
9999

100100
pkgPath := nameToImport[:i]
101101
localName := nameToImport[i+1:]
102+
if pkgPath == g.Config.pkgPath {
103+
return prefix + localName, nil
104+
}
102105
alias, ok := g.imports[pkgPath]
103106
if !ok {
104107
if g.importsLocked {

generate/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
"github.com/alexflint/go-arg"
1515
)
1616

17+
// TODO(benkraft): Make this mockable for tests?
18+
func warn(err error) {
19+
fmt.Println(err)
20+
}
21+
1722
func readConfigGenerateAndWrite(configFilename string) error {
1823
var config *Config
1924
var err error

0 commit comments

Comments
 (0)