Skip to content

feat(sdk): fetch only the named fields with only - #1423

Open
saltas888 wants to merge 41 commits into
infrahub-developfrom
ds/infp-532-only-field-selection
Open

saltas888 wants to merge 41 commits into
infrahub-developfrom
ds/infp-532-only-field-selection

Conversation

@saltas888

@saltas888 saltas888 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Why

Developers who write integrations, scripts and generators with the SDK cannot ask for only some fields of a node. Every get(), all() and filters() query fetches the default set: every attribute and most relationships. include adds to that set and exclude removes from it, so a query fetches more than the code reads and grows each time someone adds a field to the schema.

Goal: a developer names the fields they need, gets those plus the fields the SDK needs, and the query stays the same when the schema grows. Adds only in the API.

Jira: INFP-532

-> Reviewed locally using claudes' /code-review high

Notes to reviewer:

DO NOT REVIEW THE CODE

  • Read public contract.md
  • Read data-model.md
  • Note1: There's some code duplication explained in here feat(sdk): fetch only the named fields with only #1423 (comment)
  • Note2: - hfid in only follows how node.hfid already works: the SDK calculates it from the node's fields and never reads the server's hfid. So naming hfid fetches the fields it needs. Nodes not from only are unchanged. Details: spec.md (FR-005).
  • Note3:is_loaded lets code check whether a field was fetched before reading it.** On a node from only, reading an unfetched field raises FieldNotLoadedError. Before this PR, a field missing from the response and a field set to null both read as None. is_loaded works on every node, including after exclude, and never raises.

Existing bugs found during end-to-end testing

These came up while testing this pull request against Infrahub 1.11.5. None of them is caused by this pull request.

saltas888 and others added 23 commits October 14, 2026 23:08
Spec, plan, research, data model, public API contract, quickstart,
critique, task breakdown and spec/ask alignment check for the `only`
parameter on get/all/filters and fetch(), `is_loaded`, and the three
new error types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add FieldNotLoadedError, FieldNotFoundError and FieldSelectionConflictError
to infrahub_sdk.exceptions, each with Error as its only base class and its
arguments kept as attributes.

Add the private module infrahub_sdk/node/_only.py with the checks and field
lists that queries using only will share: the conflict check with include
and exclude, the name check for a kind and its implementing kinds, the check
that every peer kind has every name, the expansion of a kind's
human_friendly_id into the fields to request, and the frozen marker for
nodes built from such a query. The module takes schemas already looked up
and does no I/O.

Baseline before this change: tests/unit/sdk 1657 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FieldSelectionConflictError takes no arguments, so comparing its attributes
compared two empty dictionaries and could not fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every attribute, cardinality-one relationship and relationship manager
built for a node is bound to it and records whether the node's data
contained the field. is_loaded reports that record, any assignment since,
or a node without an id; it never raises and reads stay unchanged.

RelationshipManager.peers is now a property over the stored list; the
setter marks the manager as loaded without changing initialized.
Internal reads used when saving go through the private storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add private query methods on InfrahubNode and InfrahubNodeSync that request
id, display_label and __typename plus the fields named in only, without the
node's hfid. Named relationships are references, and get their peers'
default fields with prefetch_relationships. Hierarchy fields are requested
only when named. hfid is replaced by the fields of each kind's HFID. For a
generic kind, fields that only some implementing kinds have go in aliased
...on Kind blocks, read from the query's branch.

The decision of which field goes where lives in _only.py, so both node
classes share it. The public generate_query_data methods do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the query text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relationship that a query with only requests just for the HFID now keeps
the reference plus the peer attributes the HFID needs, with or without
prefetch_relationships. Only relationships and hierarchy fields named in only
get their peers' default fields, so the query no longer grows when the HFID
peer kind gains a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get, all and filters on both clients take only, the list of fields to
fetch. Combining it with include or exclude raises
FieldSelectionConflictError before any request, and a name that is not a
field of the kind raises FieldNotFoundError before the data query.

Nodes from a query with only, and the peers built with them, record the
only list. Peers are built only for the relationships named in only when
prefetch_relationships is set (parent and children for the hierarchy
fields) and for the relationships the HFID needs, reading the node's data
with aliases removed so relationships that only an implementing kind has
are included. Each node keeps its HFID peers, so its HFID does not depend
on the store.

The HFID is calculated with a private path reader that treats a field the
node's data did not contain as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only did not fetch

Reads of an attribute value, a cardinality-one relationship's peer and identifiers, and a
cardinality-many relationship's peer list now raise FieldNotLoadedError on a node returned
by a query with only, or on a peer returned with it, when the response did not contain the
field. The error names the kind, the field, the only list and, for a peer, the relationship.
Nodes without the marker read exactly as before.

Saving, upserting, updating, the HFID, the store, mutation-result processing and repr keep
working on these nodes; the update mutation carries the id and the changed fields only.
The Raises sections of get_path_value, get_flat_value, matches_local_checksum,
download_file and upload_if_changed name the new error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RelatedNode.fetch(only=...) passes only to get. When the cached peer
was fetched with only and its HFID cannot be calculated, hfid and
hfid_str return the HFID the reference carried.

RelationshipManager.fetch(only=...) checks the names against the
declared peer kind before any request, reloads a relationship that is
not loaded (on a node from only with only=[name] and
populate_store=False, which also works for ancestors and descendants),
checks the names against every actual peer kind, then runs one filters
call per peer kind with only. Without only, both fetch methods behave
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rahub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a section on only to the query guide, fetch(only=...) under fetching
relationships manually, a sentence on nodes from only in the store guide,
and a changelog fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…errors

- Compute the field plan once per filters call and pass it to the query
  builder, which no longer looks up the implementing schemas, re-checks
  the names or takes fragment. The plan carries the only list, and each
  kind's fields carry that kind's schema.
- Mark the nodes and peers of a query with only from one node method,
  with the markers and peer plan built once per kind.
- Reject only given as a string with a TypeError.
- Make FieldNotLoadedError, FieldNotFoundError and
  FieldSelectionConflictError survive pickle and copy.
- Stop suggesting fetch(only=[field]) on a peer, which would replace the
  peer with one that carries that field alone.
- Raise again when a relationship name does not match a field.
- Correct the docstrings of fetch, is_loaded and get, and regenerate the
  reference pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Make the device relationship of the example interface schema mandatory,
  as a human-friendly ID through a relationship requires.
- Say that hfid can add a hierarchy field to the query, that
  download_file(skip_if_unchanged=True) raises only for an existing dest
  file, and that fetch(only=...) on a relationship of cardinality many
  sends a count query and then one query per page for each peer kind.
- State which store entries a node from a query with only replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Move the schema, ids, mocked responses and client helpers that several
  test modules use to tests/helpers/only_data.py, so no test module
  imports another one.
- Define the only_clients fixture once in the sdk conftest, with one
  schema that holds every kind the tests use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Test the reload of a relationship before the check of each peer kind
  in its own test, from a shelter whose animals were not fetched, and
  assert the reload request; a mocked edge may no longer hold fields the
  query with only did not request.
- Test that add, extend and remove on a relationship not fetched of a
  node from only raise UninitializedError.
- Test that unknown names in include and exclude are ignored.
- Test the HFID of a default node over every path form against its
  path value.
- Test RelatedNode.fetch with an unknown name in only.
- Read hfid_str and peer_hfids_str from fetched peers, so the fetched
  cases expect real strings.
- Add integration tests of RelationshipManager.fetch with only on a
  person's animals, and of all with only and prefetch_relationships.
- Remove helper tests that repeated other cases or asserted constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the TypeError raised when only is a string to the Raises sections of
get, all and filters on both clients and of the four fetch methods, and
regenerate the reference pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes that do not come from a query with only resolve their HFID with the
public path lookup again, so a path without a property on an attribute the
data did not contain gives the same result as before this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saltas888 saltas888 added the type/feature New feature or request label Oct 15, 2026
@github-actions github-actions Bot added the type/documentation Improvements or additions to documentation label Oct 15, 2026
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.60956% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/node/relationship.py 90.12% 5 Missing and 3 partials ⚠️
infrahub_sdk/node/node.py 98.82% 0 Missing and 2 partials ⚠️
infrahub_sdk/protocols_base.py 50.00% 0 Missing and 2 partials ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1423      +/-   ##
====================================================
+ Coverage             86.86%   87.36%   +0.50%     
====================================================
  Files                   152      153       +1     
  Lines                 14813    15221     +408     
  Branches               1982     2056      +74     
====================================================
+ Hits                  12867    13298     +431     
+ Misses                 1382     1367      -15     
+ Partials                564      556       -8     
Flag Coverage Δ
integration-tests 42.62% <58.56%> (+0.24%) ⬆️
python-3.10 61.73% <74.70%> (+0.81%) ⬆️
python-3.11 61.71% <74.70%> (+0.79%) ⬆️
python-3.12 61.71% <74.70%> (+0.79%) ⬆️
python-3.13 61.71% <74.70%> (+0.79%) ⬆️
python-3.14 61.71% <74.70%> (+0.79%) ⬆️
python-filler-3.12 23.07% <22.90%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/client.py 85.90% <100.00%> (+0.59%) ⬆️
infrahub_sdk/exceptions/__init__.py 100.00% <ø> (ø)
infrahub_sdk/exceptions/base.py 95.39% <100.00%> (+0.68%) ⬆️
infrahub_sdk/node/_only.py 100.00% <100.00%> (ø)
infrahub_sdk/node/attribute.py 100.00% <100.00%> (ø)
infrahub_sdk/node/related_node.py 95.13% <100.00%> (+4.03%) ⬆️
infrahub_sdk/node/node.py 89.49% <98.82%> (+1.71%) ⬆️
infrahub_sdk/protocols_base.py 77.44% <50.00%> (-0.86%) ⬇️
infrahub_sdk/node/relationship.py 89.77% <90.12%> (+7.90%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The HFID of a node from a query with only is read with the fields the
node fetched; check every path form, including fields it did not fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6d00aaa
Status: ✅  Deploy successful!
Preview URL: https://0c02088c.infrahub-sdk-python.pages.dev
Branch Preview URL: https://ds-infp-532-only-field-selec.infrahub-sdk-python.pages.dev

View logs

saltas888 and others added 2 commits October 15, 2026 11:26
- A hierarchy field that the kind also declares as a relationship now uses
  the declared peer kind. An HFID that reads an attribute only the parent
  kind has, such as parent__code__value, now gets that attribute. A peer
  attribute that the peer kind lacks raises FieldNotFoundError instead of
  being left out of the query.
- Every node resolves its HFID with one reader, which skips only the
  fields that a node from only did not fetch. The HFID of a node from a
  default query no longer raises when its peer in the store came from a
  query with only.
- The default query wraps hierarchy fields with the same helper as the
  query with only.
- fetch() passes only to the client only when it is set, so client
  subclasses written before only keep working.
- Relationship managers read their node from self.node instead of a
  second field, and all() no longer repeats the conflict check that
  filters() runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elds

Update the contract, the data model and the spec for the declared peer
kind of hierarchy fields, the new FieldNotFoundError case and the single
HFID reader. Add a section to the implementation report that lists the
changes in 909a293.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saltas888 and others added 3 commits October 15, 2026 11:33
…s it

A relationship manager always has a node, so the test that checks field
objects built without a node covers attributes and cardinality-one
relationships; managers on a node without an id are covered by the
new-node test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SDK calculates node.hfid from the node's fields and never reads the
server's hfid, which is why naming hfid in only fetches those fields.
State that in the HFID docstrings, test it, and shorten the description
of the only argument on get, all and filters. The reference pages are
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread infrahub_sdk/node/node.py

return data

async def _generate_only_query_data(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up refactoring: build the only query with the default builder (generate_query_data / generate_query_data_node) instead of this separate builder and its sync copy.

What it needs first:

  • A private way for the default builder to request a relationship without fetching its peer's fields. Today include does both. No new public parameter.
  • node.hfid from the server's hfid for nodes from only, since the default builder can't request "a reference plus one peer attribute".
  • Fixes for three gaps in the default builder:
    • _process_hierarchical_fields passes the node's exclude to the hierarchy peers;
    • hierarchy fields are not processed on hierarchical generics;
    • the fragment loop drops fields that an implementing kind inherits from another generic.

A local probe built the query both ways for every case in tests/unit/sdk/test_node_only_query.py: 20 of 32 matched; the rest differ for the reasons above.

saltas888 and others added 2 commits October 15, 2026 12:46
The reload in RelationshipManager.fetch and the query building in
_build_filters_query each repeated a call in two branches that differed
only in a few arguments. Build the arguments first and call once, in the
async and the sync code. The requests are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saltas888
saltas888 marked this pull request as ready for review October 15, 2026 10:56
@saltas888
saltas888 requested a review from a team as a code owner October 15, 2026 10:56
Brings in the number pool changes (#1412, #1424) so that Infrahub develop, which pins 583bc73, can run its CI against this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- **FR-002**: For the queried node, a query with `only` MUST fetch `id`, `display_label` and `__typename`, plus the named attributes and relationships. The server's `hfid` field MUST NOT be fetched for the queried node.
- **FR-003**: A relationship named in `only` MUST return its peers as references (`id`, `hfid`, `display_label`, `__typename`), as a relationship in the default set does today.
- **FR-004**: With `prefetch_relationships=True`, the peers of the relationships named in `only` MUST come back with their default fields. Relationships not named in `only` MUST NOT be queried.
- **FR-005**: In `only`, `id` and `display_label` MUST be accepted and change nothing. `hfid` MUST be replaced by the fields that make up the kind's HFID, including one field of a peer for an HFID component that goes through a relationship. Those fields MUST be readable like named fields, and those peers MUST go to the client store as peers of a relationship named in `include` do. For a generic kind, the HFID of each implementing kind applies to the nodes of that kind. For a kind without an HFID, naming `hfid` MUST change nothing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main reason to have this only parameter is for the sake of performance to avoid over fetching and only collect what we need. The benefits of this is a) less data on the wire to transfer b) less work for the infrahub server.

It seems like this choice could be sensible for schemas that only have attribute local components that compose the hfid of a node. However if the hfid has a part tied to a remote node through a relationship that lookup is a lot heavier for the backend than an extra attribute lookup (as the hfid is).

Say we have a sample schema:

  - name: Character
    namespace: Rpg
    label: "Character"
    icon: "mdi:shield-person"
    default_filter: name__value
    generate_template: true
    order_by:
      - name__value
    display_label: "{{ name__value }}"
    human_friendly_id: [name__value, profession__name__value]
    #uniqueness_constraints: [[name__value]]
    #display_labels: null
    # display_label: "name__value"
    inherit_from:
      - LineageOwner
      - LineageSource
      - CoreArtifactTarget
      - RpgEntity
    attributes:
      - name: name
        description: The name of the character
        kind: Text
        unique: true
      - name: description
        description: Brief description
        kind: Text
        optional: true
    relationships:
      - name: profession
        peer: RpgClass
        optional: false
        cardinality: one
        kind: Attribute

This is just a snippet but the relevant part is the hfid that contains a related peer.

If we start the SDK with INFRAHUB_ECHO_GRAPHQL_QUERIES=true we get something like this:

>>> client.all(kind="RpgCharacter", only=["hfid"])

query All_RpgCharacter ($offset: Int!, $limit: Int!) {
    RpgCharacter(offset: $offset, limit: $limit) {
        count
        edges {
            node {
                id
                display_label
                __typename
                name {
                    value
                }
                profession {
                    node {
                        id
                        hfid
                        display_label
                        __typename
                        name {
                            value
                        }
                    }
                }
            }
        }
    }
}

It could be that this decision was to avoid querying for an extra attribute, but the cost is instead that we need to include a relationship to the mix, then by default we also search for the hfid and display_label on the remote peer.

Typically you'd see this on Interface nodes where the hfid would combine the local attribute "name" with a relationship to a Device that also has an attribute called "Name". One of the reasons we introduced the hfid "attribute" was to avoid a dynamic lookup where the device would be evaluated as part of the query. As such I think this has to change.

saltas888 and others added 3 commits October 16, 2026 16:45
On a node not from only, fetch() on ancestors or descendants raised
ValueError before any request, because the reload removed the
relationship's name from a list of schema fields that never contains it.
Build the exclude list without that name instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use a TypedDict for the shared builder arguments in _build_filters_query,
so mypy and ty check their names and types again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check the query and the stored peer when parent declares a concrete kind
and prefetch_relationships is set, and rename the test that compares the
prefetch with the default query to say it holds when the generic is the
declared peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saltas888

Copy link
Copy Markdown
Contributor Author

Infrahub's CI passed against this branch at commit 907694f, in opsmill/infrahub#11108: a draft on develop that only moved the python_sdk submodule. 39 checks passed and none failed. 16 were skipped because a submodule change does not match their path filters.

The jobs that ran include the backend unit tests (Python 3.12, 3.13 and 3.14), the component, functional, integration and Docker integration tests, the end-to-end tests and the benchmarks.

The three commits pushed after 907694f (4a2ddfb, cf442ef and 9b5f70d) change infrahub_sdk/client.py and infrahub_sdk/node/relationship.py and were not part of that run.

saltas888 and others added 4 commits October 16, 2026 17:02
- hfid in only now adds the server's hfid field to the queried node
  instead of the fields the HFID is made of, so the query no longer
  follows a relationship to read a peer attribute for an HFID part.
- A node from that query returns the server's value from hfid, hfid_str
  and the get_human_friendly_id methods, None when it is empty or null,
  and the store indexes the node by it. Without hfid in only, the HFID
  is still calculated from the fetched fields. Nodes from default
  queries and peers keep calculating theirs.
- Remove the HFID expansion, the HFID peers kept on a node, and the
  FieldNotFoundError raised for an HFID peer attribute that the peer
  kind lacks. Hierarchy fields keep using the peer kind that the kind
  declares for them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The spec, research, plan, data model, contract and quickstart now say
  that hfid in only requests the HFID the server stores, and no longer
  describe the HFID fields, the HFID peers or the declared peer kind of
  hierarchy fields. Research R5 gives the reason, with links to the
  server code and the review thread.
- The alignment check records the owner decision of 2026-10-16, and the
  implementation report lists the change.
- The query guide describes hfid in only as it now works, and the store
  guide says when a lookup by HFID returns the earlier copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anges

A node from a query with only that named hfid returns the HFID the server
stored. After a local change to an attribute or relationship the HFID is
built from, that value no longer matches the node, so the HFID is
calculated from the fetched fields instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saltas888 added a commit to opsmill/infrahub that referenced this pull request Oct 16, 2026
…70d (do not merge)

Merges develop at 11fe17d and moves python_sdk to the current head of opsmill/infrahub-sdk-python#1423, to run Infrahub CI against the commits pushed after 907694f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saltas888 and others added 2 commits October 16, 2026 17:03
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The contract and the query guide say again that parent and children
  use the peer kind that the kind declares for them, or the hierarchy
  generic when it declares none; the stored-HFID change keeps that
  behaviour. Research R4 and R15 now say so too.
- The implementation report points at the rebased commits, records the
  change that stops using the stored HFID after a local edit, and gives
  the test results on the rebased branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/documentation Improvements or additions to documentation type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants