Skip to content

feat(generator): unions expose a base even when the spec inlines the shared properties - #59

Open
giraffesyo wants to merge 3 commits into
canaryfrom
union-base-inlined-properties
Open

feat(generator): unions expose a base even when the spec inlines the shared properties#59
giraffesyo wants to merge 3 commits into
canaryfrom
union-base-inlined-properties

Conversation

@giraffesyo

Copy link
Copy Markdown
Member

Follow-up to #56, which shipped in v0.2.7 but keys on allOf: producers that flatten composition emit branches with identical inlined properties and no allOf anywhere, so those specs got no Base() at all.

What changed

The analyzer now has two paths, in order:

  1. Composed base (unchanged): every variant embeds the same single type, and the accessor returns its address.
  2. Structural intersection (new): otherwise, intersect the variants' declared fields and synthesize <Union>Base from what they all agree on.

The inlined repro from the issue now generates:

// PetBase - The properties every variant of Pet declares.
type PetBase struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Age  int64  `json:"age"`
}

func (u Pet) Base() *PetBase {
	switch v := u.Value.(type) {
	case Dog:
		return &PetBase{ID: v.ID, Name: v.Name, Age: v.Age}
	case Cat:
		return &PetBase{ID: v.ID, Name: v.Name, Age: v.Age}
	}
	return nil
}

Rules

  • Fields match on everything that shapes the generated Go: name, wire name, type, required-ness, pointer-ness, omitempty, read/write-only, deprecation. age: integer in one branch and age: string in another drops out, as does a property one branch omits.
  • The discriminator is excluded. Otherwise a union whose branches share only kind would spawn a base holding nothing but the tag. It also makes the two paths agree: a spec that spells the base out through allOf puts kind in the branches, not the base. UnknownDiscriminator() still covers the unknown case.
  • Synthesis needs at least two distinct struct variants, so a nullable anyOf: [Person, null] does not duplicate Person.
  • The synthesized name goes through the naming scope, so a spec with its own PetBase keeps it and the synthesized one gets a distinct name.
  • Variant structs are untouched. Embedding the synthesized base into them instead would break every keyed composite literal (Dog{ID: ...}) in already-released clients.

One behavior change to review

testdata/complex-schemas.yaml now yields 18 types rather than 17. Circle and Rectangle both declare shapeType, and the request-body union dispatches on kind, so shapeType is a property the variants genuinely share and gets a base. That is the rule working, and the test expectation is updated with that note.

Ask 2 (hoisting onto the wrapper) is not in this PR

Not implemented, for three reasons:

  1. Duplicate state that silently drifts. Value is the decoded variant; hoisted fields would be a second copy. p.Name = "x"; json.Marshal(p) writes the old name, because MarshalJSON marshals Value. Making the wrapper authoritative on marshal does not fix the in-Go divergence, it moves it.
  2. It breaks comparability. A shared property that is an array or object makes the wrapper uncomparable, which propagates to every struct holding the union. TestUnionIsComparable pins this today. Gating hoisting on "the shared fields happen to be comparable" would make the generated shape depend on incidental spec details.
  3. Name collisions with the wrapper's own surface: Value, Raw, Base, MarshalJSON.

The nil return is also load-bearing under the unknown-variant rule: for an unrecognized discriminator there is no decoded variant to read shared fields from, and hoisted fields would read as zero values instead.

Tests

  • internal/analyzer/unionbases_test.go: synthesis from inlined properties, composed base still preferred, only identical properties survive the intersection, discriminator-only shares nothing, synthesized name avoids the spec's own, and two struct variants are required.
  • internal/generator/e2e_union_base_test.go: generates the inlined spec, then compiles and runs a test that compares *p.Base() against the expected PetBase for both variants, checks nil for an unrecognized kind, and confirms the variant keeps its own fields and round-trips through JSON.

gofmt, go vet ./..., and go test ./... pass.

…shared properties

Base() keyed on allOf, so it never fired for producers that flatten composition:
the branches reach the generator with identical inlined properties and no allOf
anywhere, and a spec written that way got no accessor at all.

The analyzer now falls back to intersecting the variants' declared fields and
synthesizes <Union>Base from what they all agree on, with the accessor copying
those fields out of the variant Value holds. Variants that do compose a shared
schema still name it directly, which keeps returning the type the caller
already knows.

Fields match on everything that shapes the generated Go: name, wire name, type,
required-ness, pointer-ness, omitempty, read/write-only, deprecation. The
discriminator is left out, since it is how the variants differ and a spec that
spells the base out keeps it out of the shared schema too. Synthesis needs two
distinct struct variants, so a nullable anyOf does not duplicate its only
variant, and the name goes through the naming scope so a spec's own PetBase
keeps it.

Variant structs are untouched: embedding the synthesized base into them would
break every keyed composite literal in already-released clients.

complex-schemas.yaml now yields 18 types rather than 17. Circle and Rectangle
both declare shapeType and the body union dispatches on kind, so shapeType is a
property the variants share and gets a base.
The in-place filter was correct, the write index never leading the read
index, but a reader has to prove that before moving on. The intersection
runs once per union at analysis time.
A consumer reading ClusterCreateBase in a generated package cannot tell it
from a schema the spec defines, which matters because its field set is a
function of what the variants happen to share: an unrelated edit to one
variant moves it.

Also guards the field copy the synthesis does. ir.Field holds only strings
and bools today, so copying with *f is complete, and a reference member
added later would alias into the base silently rather than failing to
compile.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant