Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docs/emittance_tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,36 @@ were measured against, so a stale row is visible as a stale row.

## Measurement of 2026-08-15

Toolkit at `8c68058`. Every tree regenerates byte-identical under `tfpfgen
provider verify`.
Toolkit at `list-identity-from-item-key`. Every tree regenerates byte-identical
under `tfpfgen provider verify`.

| Document | Provider tree files | Resources | Data sources | List resources | Actions | Builds |
|---|---|---|---|---|---|---|
| Jamf Pro | 3915 | 76 | 211 | 39 | 101 | yes |
| GitHub | 4406 | 59 | 323 | 23 | 77 | yes |
| ThousandEyes | 1798 | 35 | 95 | 11 | 51 | yes |
| Total | 10119 | 170 | 629 | 73 | 229 | |
| GitHub | 4442 | 59 | 323 | 29 | 77 | yes |
| ThousandEyes | 1912 | 35 | 95 | 30 | 51 | yes |
| Total | 10269 | 170 | 629 | 98 | 229 | |

Refusals, by the stage that refused:

| Document | Total | Derivation | Binding | Emission |
|---|---|---|---|---|
| Jamf Pro | 255 | 102 | 136 | 17 |
| GitHub | 845 | 330 | 494 | 21 |
| ThousandEyes | 361 | 87 | 251 | 23 |
| Total | 1461 | 519 | 881 | 61 |
| GitHub | 839 | 330 | 494 | 15 |
| ThousandEyes | 342 | 87 | 251 | 4 |
| Total | 1436 | 519 | 881 | 36 |

Binding refuses most of what is refused, and that is the expected shape: it is
the only stage that resolves a drafted mapping against the SDK that was
actually generated, so it is where a document's ambition meets what the
backend could carry.

Eleven of the emission refusals are one shape: a list element whose key the
document spells its own way, where no rule derives that spelling from the path
— `/roles/{id}` beside an element carrying `roleId`, `/users/{id}` beside
`uid`. Each names its candidates in its reason. They need the field named as
data before they can publish an identity.

## The documents

Each is pinned by SHA-256 in its own provider repo's `spec/upstream.lock.json`.
Expand Down
30 changes: 29 additions & 1 deletion internal/emit/render_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func resourceIdentity(r *ir.Resource) []identityAttribute {
if r.Schema == nil {
return nil
}
addressing := addressingNames(r.Operations.Read, r.Operations.Create, r.Operations.Delete)
addressing := identityAddressing(r.Operations.Read, r.Operations.Create, r.Operations.Delete)

var out []identityAttribute
var carriesID bool
Expand Down Expand Up @@ -84,6 +84,34 @@ func resourceIdentity(r *ir.Resource) []identityAttribute {
return out
}

// identityAddressing is the path parameters that scope an object, which is
// every one but the parameter naming the object itself.
//
// That last parameter is the id, and the id is added to the identity by name.
// Counting it as addressing as well puts one value in the identity twice
// wherever the document also declares it as a property — /alerts/rules/{ruleId}
// beside a ruleId field — and the duplicate is required for import and
// required of every list result, which only the resource can supply.
//
// A path not ending in a parameter addresses a collection, so every parameter
// on it is a parent.
func identityAddressing(operations ...*ir.Operation) map[string]bool {
names := map[string]bool{}
for _, operation := range operations {
if operation == nil {
continue
}
parameters := operation.PathParameters
if len(parameters) > 0 && strings.HasSuffix(operation.PathTemplate, "}") {
parameters = parameters[:len(parameters)-1]
}
for _, parameter := range parameters {
names[ir.TerraformName(parameter.Name)] = true
}
}
return names
}

// identitySchemaDecls renders the identity schema's attribute declarations,
// ready to sit inside a map[string]identityschema.Attribute literal.
//
Expand Down
82 changes: 82 additions & 0 deletions internal/emit/render_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package emit

import (
"reflect"
"testing"

ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation"
)

// names of a resource's identity, in order, for comparison.
func identityNames(identity []identityAttribute) []string {
out := make([]string, 0, len(identity))
for _, a := range identity {
out = append(out, a.Name)
}
return out
}

// An identity names one object: the parents that scope it, then its id. The
// parameter naming the object itself is the id and is not also addressing,
// which matters wherever the document declares that key as a property too —
// the identity would otherwise require the same value under two names, of
// every import and of every list result.
func TestUnit_ResourceIdentity_TheItemKeyIsTheIDAndNotAlsoAddressing(t *testing.T) {
for _, tc := range []struct {
name string
read *ir.Operation
tree *ir.AttributeTree
want []string
}{
{
name: "the body declares the item key as a property",
read: &ir.Operation{Kind: ir.OperationRead, Method: "GET",
PathTemplate: "/alerts/rules/{ruleId}",
PathParameters: []ir.Parameter{{Name: "ruleId", Type: ir.TypeString}}},
tree: &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "id", WireName: "ruleId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
{Name: "rule_id", WireName: "ruleId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
}},
want: []string{"id"},
},
{
name: "a parent scopes the object",
read: &ir.Operation{Kind: ir.OperationRead, Method: "GET",
PathTemplate: "/repos/{owner}/hooks/{hookId}",
PathParameters: []ir.Parameter{
{Name: "owner", Type: ir.TypeString},
{Name: "hookId", Type: ir.TypeString},
}},
tree: &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "owner", WireName: "owner", Kind: ir.TypeString, ComputedOptionalRequired: ir.Required},
{Name: "id", WireName: "hookId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
{Name: "hook_id", WireName: "hookId", Kind: ir.TypeString, ComputedOptionalRequired: ir.Computed},
}},
want: []string{"owner", "id"},
},
} {
t.Run(tc.name, func(t *testing.T) {
got := identityNames(resourceIdentity(&ir.Resource{
Schema: tc.tree,
Operations: ir.Operations{Read: tc.read},
}))
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("identity = %v, want %v", got, tc.want)
}
})
}
}

// A collection path names no object, so every parameter on it is a parent
// and none of them is dropped as an item key.
func TestUnit_ResourceIdentity_ACollectionPathKeepsEveryParameter(t *testing.T) {
got := identityAddressing(&ir.Operation{
Kind: ir.OperationCreate, Method: "POST",
PathTemplate: "/orgs/{org}/teams",
PathParameters: []ir.Parameter{
{Name: "org", Type: ir.TypeString},
}})
if !got["org"] {
t.Errorf("a collection path's parameter is addressing, got %v", got)
}
}
31 changes: 30 additions & 1 deletion internal/emit/render_listresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package emit
import (
"fmt"
"path"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -236,7 +237,9 @@ func (e *serviceRenderer) listResource(lr *ir.ListResource, lb *sdkbind.ListReso
func listResultLines(nodes []node, identity []identityAttribute, config []node) (string, error) {
idNode, ok := findIdentityNode(nodes)
if !ok {
return "", unrenderable("the element carries no scalar id attribute to publish as the list identity")
return "", unrenderable(
"the element publishes no identity: it carries no readable scalar %q%s",
idAttributeName, identityCandidates(nodes))
}

configured := map[string]bool{}
Expand Down Expand Up @@ -277,6 +280,32 @@ func listResultLines(nodes []node, identity []identityAttribute, config []node)
return b.String(), nil
}

// identityCandidates names the readable scalars whose spelling suggests they
// key the object, so a refusal says what the element does carry rather than
// only what it lacks. An operator reads it to decide which one to name in a
// correction; without it the only way to find them is to open the document.
func identityCandidates(nodes []node) string {
var found []string
for _, n := range nodes {
if n.attr.Nested != nil || n.fb == nil || n.fb.Access.Get == "" {
continue
}
switch n.attr.Kind {
case ir.TypeString, ir.TypeInt64, ir.TypeFloat64:
default:
continue
}
if strings.HasSuffix(n.attr.Name, "id") {
found = append(found, n.attr.WireName)
}
}
if len(found) == 0 {
return ", and no readable scalar it carries is spelled like a key"
}
sort.Strings(found)
return fmt.Sprintf(", though it carries %s", strings.Join(found, ", "))
}

// findStringNode finds a plain string attribute by name.
func findStringNode(nodes []node, name string) (node, bool) {
for _, n := range nodes {
Expand Down
88 changes: 88 additions & 0 deletions internal/emit/render_listresource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,91 @@ func TestUnit_ListResource_WithoutAddressingDeclaresNoConfiguration(t *testing.T
t.Errorf("an unparameterised collection path is mocked by exact URL:\n%s", test)
}
}

// An API that spells its key after the thing it identifies gives the element
// an id whose wire name is the item path key, which is what derivation now
// puts there. Emission has to publish that as the identity: refusing it lost
// every entity whose document simply worded its key differently.
func TestUnit_ListResource_PublishesAnIdentityKeyedTheAPIsWay(t *testing.T) {
m, b := fictionalModel(), fictionalBindings()

lr := &m.ListResources[0]
if lr.Names.Key != "http_server" {
t.Fatalf("the fictional list resource moved: %q", lr.Names.Key)
}
// The element carries the key as the API words it, not as "id".
for i := range lr.Schema.Attributes {
if lr.Schema.Attributes[i].Name == "id" {
lr.Schema.Attributes[i].WireName = "httpServerId"
}
}
lb := b.ListResources["http_server"]
fields := make([]sdkbind.FieldBinding, 0, len(lb.Fields))
for _, f := range lb.Fields {
if f.Attr == "id" {
f.Wire = "httpServerId"
f.Access = readOnly(kAccess("HttpServerId", "*string", "FromPtrString", "", ""))
}
fields = append(fields, f)
}
lb.Fields = fields

out, err := RenderServices(fictionalProviderCore(), m, b)
if err != nil {
t.Fatalf("an element keyed the API's way must still render: %v", err)
}
list := string(fileByPath(t, out, "internal/services/list-resources/servers/v7/http_server/list.go").Content)
if !strings.Contains(list, "GetHttpServerId()") {
t.Errorf("the identity is not read from the element's own key:\n%s", list)
}
if !strings.Contains(list, "identityModel{ID: types.StringValue(id)}") {
t.Errorf("the element's key is not published as the identity:\n%s", list)
}
}

// The refusal an element with no key at all still earns has to say what it
// looked for and what the element does carry, or the only way to write the
// correction is to read the toolkit.
func TestUnit_ListResource_RefusalNamesWhatTheElementCarries(t *testing.T) {
m, b := fictionalModel(), fictionalBindings()

lr := &m.ListResources[0]
kept := make([]ir.Attribute, 0, len(lr.Schema.Attributes))
for _, a := range lr.Schema.Attributes {
if a.Name == "id" {
a.Name, a.WireName = "server_uid", "serverUid"
}
kept = append(kept, a)
}
lr.Schema.Attributes = kept
lb := b.ListResources["http_server"]
fields := make([]sdkbind.FieldBinding, 0, len(lb.Fields))
for _, f := range lb.Fields {
if f.Attr == "id" {
f.Attr, f.Wire = "server_uid", "serverUid"
f.Access = readOnly(kAccess("ServerUid", "*string", "FromPtrString", "", ""))
}
fields = append(fields, f)
}
lb.Fields = fields

out, err := RenderServices(fictionalProviderCore(), m, b)
if err != nil {
t.Fatalf("one refused list resource must not fail the run: %v", err)
}

var reason string
for _, e := range out.Excluded {
if e.Key == "http_server" {
reason = e.Reason
}
}
if reason == "" {
t.Fatalf("the refusal was not reported: %+v", out.Excluded)
}
for _, want := range []string{`no readable scalar "id"`, "serverUid"} {
if !strings.Contains(reason, want) {
t.Errorf("the refusal does not mention %q: %s", want, reason)
}
}
}
16 changes: 15 additions & 1 deletion internal/intermediate_representation/derive.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,24 @@ func (derivation *deriver) listResource(classification specmodel.Classification,
listOperation := *derivation.operation(classification.List, OperationList)
addressing := addressingSchema(listOperation.PathParameters)
refuseReservedRootNames(addressing)

// A list result is an identity, and an identity is the resource's id. The
// resource takes its id from the item path key where the object declares
// no property of that name, and the element has to answer the same key by
// the same rule: /tests/{testId} keys the object on testId whether it is
// being read one at a time or streamed.
//
// Without this the element kept only the document's own spelling, and an
// API that does not happen to call its key "id" published no identity at
// all — which refused the entity outright, for a difference in wording.
tree := buildTree(nil, element, nil, false)
keyParam, keyType := itemKeyParam(classification.ItemPath, derivation.full(classification.Read))
ensureID(tree, keyParam, keyType)

return ListResource{
Names: names,
ListOperation: listOperation,
Schema: buildTree(nil, element, nil, false),
Schema: tree,
AddressingSchema: addressing,
ListEnvelopeKey: listEnvelopeKey(listFull),
}
Expand Down
Loading