Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Draft: Add ResourceFilter composition#10328

Draft
jptrindade wants to merge 15 commits into
masterfrom
resource-filter-proposal-core
Draft

Draft: Add ResourceFilter composition#10328
jptrindade wants to merge 15 commits into
masterfrom
resource-filter-proposal-core

Conversation

@jptrindade

@jptrindade jptrindade commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Description

Introduces ResourceFilterABC which any extension can implement and register on the GraphQLSlice.
These implementations are then composed into a single ResourceFilter that is then exposed to the user on GraphQL.

closes Add ticket reference here

Self Check:

Strike through any lines that are not applicable (~~line~~) then check the box

  • Attached issue to pull request
  • Changelog entry
  • Type annotations are present
  • Code is clear and sufficiently documented
  • No (preventable) type errors (check using make mypy or make mypy-diff)
  • Sufficient test cases (reproduces the bug/tests the requested feature)
  • Correct, in line with design
  • End user documentation is included or an issue is created for end-user documentation (add ref to issue here: )
  • If this PR fixes a race condition in the test suite, also push the fix to the relevant stable branche(s) (see test-fixes for more info)

@jptrindade jptrindade self-assigned this Apr 27, 2026
Comment thread src/inmanta/graphql/graphql.py Outdated
Comment on lines +77 to +78
if hasattr(base, "apply_filters"):
stmt = base.apply_filters(self, stmt)

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.

This raises the question to me: do we want to call each extension's filter, or only if any of their filter fields are set?

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.

I feel like we can call it always. The extension's apply_filter should (need to?) have logic to determine if they are being called or not.

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.

This way we can also inject some logic even if the extension does not have any filter present

Comment thread src/inmanta/graphql/graphql.py Outdated
Comment on lines +77 to +78
if hasattr(base, "apply_filters"):
stmt = base.apply_filters(self, stmt)

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.

base.apply_filters(self, stmt)

Good enough for the PoC, but something to figure out how we'll clean it up for the actual implementation. StrawberryFilter defines apply_filter as an instance method. You typically call them on an instance. That is what you would expect. But you now forcefully call them on the class and have to pass in the instance.

I think that one solution would be the separation between data and logic that I drafted, but that had complications of its own. Another could perhaps be to use a class method instead.

Comment thread src/inmanta/graphql/graphql.py Outdated
class ExampleResourceFilter(StrawberryFilter):
my_attr: StrFilter | None = None

def apply_filters[*Ts](self, stmt: Select[tuple[*Ts]]) -> Select[tuple[*Ts]]:

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.

Great for the draft. For the eventual interface it's still unclear to me what we want this to be exactly. Mostly does it modify the select or does it simply return a join directive.

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.

Like a cte that we then join to filter on?
I feel like modifying the select might be clearer. wdyt?

Comment thread src/inmanta/graphql/schema.py Outdated
"""

loader = StrawberrySQLAlchemyLoader(async_bind_factory=get_session_factory())
ResourceFilter = context.graphql_service.build_resource_filter()

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.

mypy still hates this

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.

Is casting this as typing.Any the best solution?

Comment thread src/inmanta/graphql/schema.py Outdated


@strawberry.input
class BaseResourceFilter(ABC):

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.

Maybe ResourceFilterABC is better

@jptrindade jptrindade force-pushed the resource-filter-proposal-core branch from 150b928 to 7e6b539 Compare April 30, 2026 11:07
@jptrindade jptrindade changed the title Draft: ResourceFilter proposal Add ResourceFilter composition May 5, 2026
@jptrindade jptrindade requested a review from sanderr May 5, 2026 16:25

@sanderr sanderr left a comment

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.

I still wonder if we should / can restrict what apply_filters() can do. And I'm still leaning towards yet. But, I think that perhaps we should carry on like this first, and once we have a clear idea of what lsm needs exactly and how it will all fit together (could even be after the first merge), we can come back to this consideration and see if we can streamline that interface just a bit more.

Comment thread src/inmanta/graphql/graphql.py Outdated
Comment on lines +61 to +62
for filter_cls in self.all_filters:
stmt = filter_cls.apply_filter(stmt=stmt, filter_instance=filter_instance)

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.

I wonder if the order ever matters here (apart from that it should be deterministic). I think not? i.e. we could safely call this on the lsm filters first and only then on the core ones?

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.

I believe that the order does not matter.

Comment thread src/inmanta/graphql/graphql.py Outdated
Comment on lines +87 to +92
def update_resource_filter(self, ext_filter: type[ResourceFilterABC]) -> None:
"""
Adds an extension's ResourceFilterABC implementation to be composed into the ResourceFilter that is exposed to the user
"""
self.resource_filter_engine.register_extension_filter(ext_filter)
self._composed_resource_filter_cls = None

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.

Do we really need to be this dynamic? I think it could be a bit more robust / consistent if we can make sure that we initialize once to start with, and reject additional filters afterwards. Wdyt?

Comment thread src/inmanta/graphql/graphql.py Outdated
from strawberry.types.execution import ExecutionResult


class ResourceFilterEngine:

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.

If we move this into graphql.schema (and perhaps make a singleton by operating at class level rather than instance level)?, can we get rid of the whole passing around of the slice and engine that's going on there? Or am I missing something?

Comment thread src/inmanta/graphql/schema.py Outdated

environment: uuid.UUID
is_orphan: bool | None = strawberry.UNSET
purged: bool | None = strawberry.UNSET

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.

Why do we need purged at this level?

Comment thread src/inmanta/graphql/schema.py Outdated
"""

environment: uuid.UUID
is_orphan: bool | None = strawberry.UNSET

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.

Do we still need is_orphan at this level, considering what we discussed earlier this week? i.e. lsm filters are always about the latest intent (we may still need to figure out how to enforce that, but that's a different matter).

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.

I put is_orphan and purged at this level to make mypy happy because they are used in the resources query.

@sanderr sanderr May 7, 2026

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.

I see.

  1. I worry that is_orphan might cause us trouble in the next stage, but let's see about that when we get there. For now it's fine here.
  2. I'm pretty sure that we'll have to update the condition that we use to determine whether or not we can use the simplified count (a complication I hadn't thought of until just now). Once we have addressed that, if we're lucky purged will have disappeared from the resources query.

Comment thread src/inmanta/graphql/schema.py Outdated

@classmethod
@abstractmethod
def apply_filter[*Ts](cls, stmt: Select[tuple[*Ts]], filter_instance: typing.Self) -> Select[tuple[*Ts]]: ...

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.

Apparently this Self isn't really safe. Took it to Slack.

Comment thread src/inmanta/graphql/schema.py Outdated
:param purged: If we want to filter on the "purged" attribute or not.
"""

environment: uuid.UUID

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.

With this move we'll have to make sure to not reintroduce the bug where we omit the environment filter. But I guess the test you expanded will protect us from that.

Comment thread src/inmanta/graphql/schema.py Outdated
"""
if filter is not None and filter is not strawberry.UNSET:
stmt = filter.apply_filters(stmt)
if isinstance(filter, ResourceFilterABC):

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.

I still need to give this some more thought to see if we can do anything about the clunkiness here. No immediate ideas yet.

@jptrindade jptrindade requested a review from sanderr May 8, 2026 14:40
Comment thread src/inmanta/graphql/graphql.py Outdated
def register_extension_filter(self, extension_name: str, filter_cls: type[ResourceFilterABC]) -> None:
"""
Register an extension filter.
This is only possible if the composed ResourceFilter has yet to be generated

@sanderr sanderr May 11, 2026

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.

I think this can be improved to better address the caller. I.e. "the composed ResourceFilter has yet to be generated" is an implementation detail. The corresponding client contract is that it must be called before this slice starts, so during the prestart stage, or the start stage of a slice that registers this one as a dependency.

Comment thread src/inmanta/graphql/graphql.py Outdated
async def start(self) -> None:
assert self.compiler_service is not None
self.schema = get_schema(
GraphQLContext(compiler_service=self.compiler_service, resource_filter_components=list(self.all_filters.values()))

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.

Why pass this through the context? It requires quite some wiring, and I don't think it really offers us anything?

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.

I think I see. You use the context so you can access it in the resources method. But wouldn't it be a lot simpler to simply store it as a class var on the Query object? Or even just use the value from the closure, though I think I prefer to make it explicit on Query.

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.

I tried setting it as a variable of Query but got some errors, needs to be a GraphQL valid type and I got some conflicts there. And I feel like context is a good "container" for all the small things that we get from the slice to the schema.

Comment thread src/inmanta/graphql/graphql.py Outdated
assert isinstance(compiler_service, CompilerService)
self.context = GraphQLContext(compiler_service=compiler_service)
self.compiler_service = compiler_service
self.all_filters[SLICE_GRAPHQL] = CoreResourceFilter

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.

Bit of a nitpick, and I don't care too strongly, but I think it could turn out slightly cleaner if we move this responsibility to get_schema. i.e. the slice knows about extensions, the schema knows about core and how to extend it with extensions. Apart from the slightly debatable semantical improvement, I think it offers the following benefits:

  • We get rid of the back-and-forth where we import a filter from the schema here, to pass it back down to the schema. This flow feels a bit weird to me.
  • We make the typing.Annotated a bit more safe, because if get_schema is the one that injects the core filter, than we actually know that the generated type will be an instance of it. Otherwise we introduce more coupling, because the typing.Annotated depends on the assumption that the slice injects the core resource filter.

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.

Not sure what you mean by this. So we wouldn't have all_filters as a concept on the slice? Or do you mean that GraphQLSlice does not register CoreResourceFilter but keeps all the other filters, and then we just append CoreResourceFilter on get_schema?

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.

Pretty much. Well, I think prepend would be safest, but that's beside the point.

Comment thread src/inmanta/graphql/schema.py Outdated
resource_filter_instances: list[ResourceFilterABC] = []
for filter_type in info.context.get("resource_filter_components"):
filter_fields = {
field.name: getattr(filter, field.name, strawberry.UNSET) for field in dataclasses.fields(filter_type)

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.

Why use getattr()? To be extra robust? It normally shouldn't ever be absent, should it?

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.

I'm not sure how would the alternative look like. Or do you mean the default to strawberry.UNSET?

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.

I was somewhat mistaken here, sorry. What I meant indeed boils down to the default.

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.

I remember I ran into some issues without the default. But that may have been from a previous iteration where we where converting to dict.

The tests seem to pass without the default, I'll remove it so we can more easily diagnose if something goes wrong

Comment thread src/inmanta/graphql/schema.py Outdated
if filter is not None and filter is not strawberry.UNSET:
stmt = filter.apply_filters(stmt)
for filter_instance in filter:
if filter_instance is not strawberry.UNSET:

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.

Not necessarily in scope for this PR, but why do we check for UNSET actually? It's not of type StrawberryFilter, is it?

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.

I think this is just an iteration artifact. I check for UNSET before I call this when applicable

@jptrindade jptrindade requested a review from sanderr May 11, 2026 15:49
@jptrindade jptrindade changed the title Add ResourceFilter composition Draft: Add ResourceFilter composition Jun 9, 2026
@jptrindade jptrindade removed the request for review from sanderr June 9, 2026 08:59
@jptrindade jptrindade marked this pull request as draft June 9, 2026 08:59
@jptrindade

jptrindade commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

I updated the draft to match the design. There are still some mypy issues that I might need some assistance with.

EDIT: I tried on the return object of the resources query CustomListConnection[typing.Annotated[CoreResourceBase, strawberry.argument(graphql_type=composed_resource_type)]] but it did not work with the other resource mixins, meaning that their fields were not available to be queried

@jptrindade jptrindade requested a review from sanderr June 11, 2026 09:40

@sanderr sanderr left a comment

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.

I tried to do a broad, high level review and to only comment on things that are relevant at that level.

Some things that come to mind that are still missing (there are probably others, I didn't cross check with the design):

  1. support for different version filters (we still branch our query on include_orphans)

Comment thread src/inmanta/graphql/schema.py Outdated
"""

@classmethod
def get_resource_filter_input_class(cls) -> type[ResourceFilterABC] | None:

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.

Interesting. I believe I proposed something like this at some point and we didn't go this way. Why did you change it? I assume it has to do with the fact that we need both input and output? How / when do they differ in their fields?

EDIT: Ah, I guess "meta" filters like includeOwned... We could probably also use annotations on the fields to determine whether they're in/out/both. But let's go with this approach for now and see how it ends up. If it looks clean, this is probably the simplest. If it turns out to be too repititive / coupled, perhaps we can reconsider.

return None

@classmethod
def get_context_loaders(cls) -> dict[str, object]:

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.

What's this? The docstring doesn't make it clear to me and I don't see it being used anywhere.


Resource: type = build_resource_return_obj(extension_contributions)

class CustomInfo(Info[StrawberryInfoContextDict, object]):

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.

I thought we were moving away from this context object?

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.

(Also related to the comment above) I don't think we can/should. The way we fetch lsm related information will be through custom resolvers (like the purged attribute). I want the extensions to inject stuff that they require here to help in the logic of the custom resolvers. This is an example that Claude made:

```python
@dataclasses.dataclass
class _LsmResourceData:
    service_entity: str
    service_instance_id: uuid.UUID
    lifecycle_state: str

Shared DataLoader helper:

async def _load_lsm_data(root: Any, info: Info) -> Optional[_LsmResourceData]:
    make_loader = info.context.get("lsm_resource_info_loader_factory")
    if make_loader is None:
        return None
    cache_key = f"_lsm_info_loader_{root.environment}"
    loader = info.context.get(cache_key)
    if loader is None:
        loader = make_loader(root.environment)
        info.context[cache_key] = loader
    return await loader.load(root.resource_id)

Three individual field resolvers (each call _load_lsm_data, extract one attribute):

async def _get_lsm_service_entity(root, info) -> Optional[str]: ...
async def _get_lsm_service_instance_id(root, info) -> Optional[uuid.UUID]: ...
async def _get_lsm_lifecycle_state(root, info) -> Optional[str]: ...

Mixin class:

class _LsmResourceMixin:
    lsm_service_entity:      Optional[str]       = strawberry.field(resolver=..., description="...")
    lsm_service_instance_id: Optional[uuid.UUID] = strawberry.field(resolver=..., description="...")
    lsm_lifecycle_state:     Optional[str]       = strawberry.field(resolver=..., description="...")

Performance: No impact from having 3 resolvers instead of 1. The DataLoader batches
all loader.load(resource_id) calls within the same async tick into a single SQL query,
and caches results by key — subsequent load() calls for the same key return the cached
_LsmResourceData without a database round-trip. Result: still one SQL query per
environment per page regardless of which or how many inline LSM fields are requested.

LsmResourceFilterFields

@strawberry.input
class LsmResourceFilterFields:
    service_entity:           Optional[StrFilter]       = strawberry.UNSET
    service_instance:         Optional[list[uuid.UUID]] = strawberry.UNSET
    lifecycle_state:          Optional[StrFilter]       = strawberry.UNSET
    service_instance_version: Optional[int]             = strawberry.UNSET
    include_owned:            Optional[bool]            = strawberry.UNSET

DataLoader for inline LSM fields

get_context_loaders() returns {"lsm_resource_info_loader_factory": make_loader} where
make_loader is a per-environment factory creating a DataLoader on first use. One
DataLoader instance per environment per request; all resources on the same page share one
batch SQL query.

The batch query fetches all three values in one shot (selecting more columns than a caller
might need is negligible cost compared to an extra round-trip):

SELECT
    elem                             AS resource_id,
    lt.resource_id_value             AS instance_id,
    lt.attributes->>'service_entity' AS service_entity,
    si.state                         AS lifecycle_state
FROM resource lt
JOIN resource_set_configuration_model rscm ON ...
JOIN (SELECT MAX(version) AS version FROM configurationmodel
      WHERE environment = $1 AND released = TRUE) lv ON rscm.model = lv.version
LEFT JOIN lsm_serviceinstance si
    ON si.id::text = lt.resource_id_value AND si.environment = lt.environment
CROSS JOIN LATERAL jsonb_array_elements_text(lt.attributes->'resources') elem
WHERE lt.environment   = $1
  AND lt.resource_type = 'lsm::LifecycleTransfer'
  AND lt.attributes->>'resources' != '<<undefined>>'
  AND elem = ANY($2)

Comment thread src/inmanta/graphql/schema.py Outdated
Comment on lines +969 to +970
return strawberry.input(
dataclasses.dataclass(kw_only=True)(type("ResourceFilter", resource_filter_components, {}))

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.

Wouldn't it be simpler to move this part to the caller so that we don't have to return a tuple? Optionally make it a second helper method tuple[type[ResourceFilterABC], ...] -> type if you don't want the complexity in get_schema()?

Comment on lines +983 to +990
_resource_annotations: dict[str, object] = {}
_resource_attrs: dict[str, object] = {}
_skip_attrs = {"__dict__", "__weakref__", "__doc__", "__annotations__", "__module__"}
for _base in (CoreResourceBase, *_resource_mixins):
_resource_annotations.update(_base.__dict__.get("__annotations__", {}))
for _k, _v in _base.__dict__.items():
if _k not in _skip_attrs:
_resource_attrs[_k] = _v

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.

This level of meta-programming scares me a bit with respect to stability. I need to think if I see a better way.

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.

Yeah, I agree that this is one of the parts that scares me the most, Claude wrote it, for better or worse

Comment thread src/inmanta/graphql/schema.py Outdated
if _k not in _skip_attrs:
_resource_attrs[_k] = _v
# Can't do the same as the resource filter because the mixins can't have the mapper.type decorator and that is required
return mapper.type(models.Resource)(type("Resource", (), {"__annotations__": _resource_annotations, **_resource_attrs}))

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.

If we use mapper.type(models.Resource), won't it prevent the query from returning anything other than a models.Resource object?

Open question. I don't feel like I understand the framework sufficiently to give an answer myself.

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.

No. It creates a strawberry type based on the attributes of models.Resource but it is not indeed the same object since we can add fields to it (we already do the same with the purged attribute for example)

Comment on lines +68 to +70
self.schema = get_schema(
compiler_service=self.compiler_service, extension_contributions=list(self.extension_contributions.values())
)

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.

Nice. I don't recall if I've already reviewed since this change, but I prefer it a lot over the previous approach.

inmantaci pushed a commit that referenced this pull request Jun 23, 2026
…ecks in the GraphQL schema with an `is_set` TypeGuard helper. (PR #10496)

# Description

Factored out of #10328.

Introduces an `is_set` TypeGuard helper in `inmanta.graphql.schema` and uses it to replace the repeated `x is not None and x is not strawberry.UNSET` checks scattered through the GraphQL filter/pagination code.

```python
def is_set[T](value: T | None) -> typing.TypeGuard[T]:
    return value is not None and value is not strawberry.UNSET
```

Because it's a `TypeGuard`, mypy narrows away `None`/`strawberry.UNSET` in the positive branch exactly like the inline check did, so there is no behavioural change — this is a pure readability refactor.

Note: the one spot that passed `getattr(...)` (typed `Any`) now annotates `attr: CustomFilter | None` so the TypeGuard narrows to a concrete type instead of `Never`.

# Self Check:

- [x] Attached issue to pull request (N/A — refactor split out of #10328)
- [x] Changelog entry
- [x] Type annotations are present
- [x] Code is clear and sufficiently documented
- [x] No (preventable) type errors (verified with mypy: no new errors vs master)
- [x] Sufficient test cases (no behaviour change; existing GraphQL filter/pagination tests cover the touched code)
- [x] Correct, in line with design

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

2 participants