Skip to content

feat(graphql): method-level alias support with three-state mutation feedback (fixes #140) - #141

Merged
allmonday merged 10 commits into
masterfrom
feat/gql-alias-support
Sep 2, 2026
Merged

feat(graphql): method-level alias support with three-state mutation feedback (fixes #140)#141
allmonday merged 10 commits into
masterfrom
feat/gql-alias-support

Conversation

@allmonday

Copy link
Copy Markdown
Collaborator

Summary

Fixes #140 and delivers full specs/023: method-level GraphQL aliases on both query paths, with per-call three-state mutation feedback and a federation wire gate that keeps member services alias-free.

Root cause fixed: QueryParser._parse_selection_set keyed sub_fields by field name, so same-name aliased invocations overwrote each other — N aliased add_node calls executed once with errors: []. Keys are now response keys (alias or name); lookups resolve via FieldSelection.name.

What you get

mutation { MindmapService {
  n1: add_node(content: "one")   { display_id }
  n2: add_node(content: "two")   { display_id }
  n3: add_node(content: "three") { display_id }
} }
  • Queries: aliased same-method fan-out with different args — each alias is an independent call, keyed by alias, projections isolated per alias; a failing alias nulls only itself (QUERY_FAILED), siblings unaffected
  • Mutations: serial in declaration order, N aliases = N side effects (no execution-level dedup). On failure: prior results survive, failing key nulls with MUTATION_FAILED, later keys are skipped and marked SKIPPED_PRIOR_FAILURE (fail-stop)
  • Conflict detection: duplicate response keys (alias repeats, alias/field collisions, plain duplicate fields — no spec field-merging) raise ALIAS_CONFLICT before any method executes
  • Federation boundary: the wire never carries aliases — _wire_render_name renders original field names (alias-gated: hand-built resolver trees reuse name for the target TYPE name, so only aliased nodes substitute). Members need zero changes
  • Out-of-scope aliases reject loudly (never silently): nested-field renames, federation remote-relationship fields, CLI --select
  • QueryParser.validate_no_aliases() keeps its semantics as an optional user-side guard; nexusx no longer calls it internally

Behavior changes to note (6.2.0, minor)

  • Same-name duplicate selections previously "last one wins" silently → now a loud ALIAS_CONFLICT
  • entity-first: one failing method no longer nulls the whole entity group — the failed key nulls with RESOLVER_ERROR, sibling results survive; mutation groups fail-stop (per specs/023 clarify Q1, both paths unified)
  • compose: execution-time failures (method body, coercion, missing FromContext) are per-field (QUERY_FAILED) instead of nulling the whole response; planning errors still fail fast

Validation

  • Full suite 1637 passed, 6 skipped (baseline 1605 + 32 new, zero regressions); ruff check src/ clean
  • Issue Mutation fields with aliases are silently collapsed — only the last one executes #140's original scenario covered end-to-end (6 aliased add_node → 6 nodes, 6 keyed results)
  • Federation matrices (β materialized / γ DTO / paginated) green incl. wire alias-free assertions
  • Spec artifacts under specs/023-gql-alias-support/ (spec/plan/research/contracts/quickstart/tasks, all 🇨🇳 per project convention); user guide at docs/guide/graphql-aliases.md

🤖 Generated with Claude Code

allmonday and others added 10 commits August 30, 2026 14:06
…apsing (US1, specs/023)

Issue #140: aliased fields in a compose query were silently collapsed to
the last one — 6 aliased add_node invocations executed just once with
errors=[]. Now execute_compose_query detects any alias at AST level
(including nested) before any service call and returns an ALIAS_CONFLICT
error naming the alias path.

Adds spec 023 (spec/plan/research/data-model/contracts/quickstart/tasks)
and its requirements checklist.

Co-Authored-By: Claude <noreply@anthropic.com>
…e gate (US2+US4, specs/023)

sub_fields keys become RESPONSE keys (alias or name); lookups use
FieldSelection.name. Same-name aliased invocations no longer overwrite
each other, and duplicate response keys (alias repeats, alias/field
collisions, plain duplicate fields — no spec field-merging) now raise
ALIAS_CONFLICT instead of silently deduplicating.

- compose: method resolution by sel.name, response keys carry aliases,
  query fans out with per-field failure isolation (QUERY_FAILED nulls
  only its own key; planning errors still fail fast)
- entity-first: response keys + projection lookups honor aliases at
  group/method level; nested aliases rejected pre-execution (FR-009)
- federation: wire renders original field names via _wire_render_name —
  aliases never leak to members (alias-gated: hand-built trees reuse
  'name' for the target TYPE, so only aliased nodes substitute)
- validate_no_aliases retired from the handler call path (kept as an
  optional user-side guard); CLI --select rejects aliases explicitly

Full suite 1630 passed (baseline 1605 + 25 new, zero regressions).

Co-Authored-By: Claude <noreply@anthropic.com>
…e-group nulling (US3, specs/023)

Mutation groups run aliased invocations serially in declaration order
(N aliases = N side effects, no execution-level dedup). On failure:
prior results survive, the failing key nulls with MUTATION_FAILED
(compose) / RESOLVER_ERROR (entity-first), and fail-stop marks every
later key SKIPPED_PRIOR_FAILURE — replacing the pre-023 semantics where
one failure nulled the whole group and erased already-executed writes
(D4, unify both paths per clarify Q1).

Full suite 1637 passed (baseline 1605 + 32 new, zero regressions).

Co-Authored-By: Claude <noreply@anthropic.com>
…sh, specs/023)

Adds docs/guide/graphql-aliases.md (quickstart examples, behavior table,
error envelope, migration notes) and records two implementation-era
findings in research.md: FieldSelection.name carries the target TYPE
name on hand-built resolver trees (wire gate is alias-gated), and
response_builder is live on the entity-first serialize path.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds 8 cases for gaps found in coverage review:
- CLI --select alias rejection (FR-009 half that had zero tests)
- top-level (service/entity-group) aliases: parser semantics + compose
  fan-out (implemented in US2 but untested)
- federation remote-relationship field alias rejected pre-execution
  (FR-009's wire-gate complement, asserts zero method execution)
- MCP compose_query end-to-end aliased fan-out

Also verified γ (DTO federation) posts plain JSON to /nexusx/dto-batch
— aliases never touch that wire, FR-008 holds by construction.

Full suite 1645 passed (was 1637 + 8 new).

Co-Authored-By: Claude <noreply@anthropic.com>
…cs/023 review)

Groundwork for the review polish:

- find_nested_alias(): single FR-009 tree walk, returning (dotted
  response-key path, original field name) so messages can render
  'tasks' aliased to 'r' instead of the bare alias key
- nested_alias_message(): shared reject wording
- ResponseKeyConflictError: ValueError subclass for duplicate response
  keys (FR-007) — existing except-ValueError callers unaffected; lets
  handlers emit a machine-readable code

Purely additive; consumers land in the following two commits. Also adds
the validate_no_aliases smoke tests (public user-side guard, previously
zero callers and zero coverage).

Co-Authored-By: Claude <noreply@anthropic.com>
…1/P2)

P1: gather(return_exceptions=True) delivers CancelledError (and
KeyboardInterrupt/SystemExit) as VALUES; the old
isinstance(value, BaseException) branch downgraded them to QUERY_FAILED
error entries — a cancelled request (client disconnect, asyncio.timeout)
then kept executing its serial mutations and returned a normal response.
Now: Exception → per-field QUERY_FAILED; other BaseException → re-raise.
The mutation path's except Exception already excluded CancelledError.

P2: error paths use response keys — the service group alias (path_key),
not the class name, matching the data keys the client received.

Polish: per-field errors carry extensions.service_method (the fail-fast
path always did) so MCP consumers locate the failing method without
parsing the message; nested-alias detection delegates to the shared
query_parser.find_nested_alias with unified wording.

Tests: TestQueryCancellationPropagates (child raise / external task.cancel
+ mutation-not-run / plain-exception regression guard), aliased-group
error path, service_method assertion.

Co-Authored-By: Claude <noreply@anthropic.com>
…2/P3 + polish)

P2: error paths and data keys both use response keys (alias or name) per
the GraphQL spec — group_key computed once in execute_query and
_execute_entity_group; validation / resolver / unknown-method / skip
paths, the bare-group error, and the introspection data key all follow.
Messages and logs keep ORIGINAL names (better for humans looking up the
schema); only path carries response keys.

P3: replace the always-true `... or True` assertion with the actual
shape — a validation-rejected method is skipped and the group serializes
as an empty object (group-level nulling was dropped in 023 D4).

Polish: nested-alias detection delegates to the shared
query_parser.find_nested_alias (drops the local duplicate walk) with
unified 'x' aliased to 'y' wording; selection.py --select rejects via
the same walk (path-qualified message); GraphQLHandler catches
ResponseKeyConflictError to emit the ALIAS_CONFLICT extensions code,
aligned with the compose path.

Co-Authored-By: Claude <noreply@anthropic.com>
…review follow-up)

Fail-stop previously only applied within a single service/entity group:
measured, a mutation failing in SvcA let SvcB.write execute anyway
(both paths). FR-006 says "calls after the failure stop" with no
group qualifier, and GraphQL mutations are serial at OPERATION level.

Both executors now propagate an abort flag across group boundaries:
later groups' mutations are skipped and marked SKIPPED_PRIOR_FAILURE;
queries are unaffected (FR-005). Contract scenario 4 semantics.

Tests: cross-group/cross-service skip + query-unaffected on both paths.

Co-Authored-By: Claude <noreply@anthropic.com>
…cs/023)

- Behavior matrix: fail-stop range is operation-level (cross-group
  propagation); query failure code QUERY_FAILED now documented
- Error structure: response-key path semantics + service_method
  extension in the example
- Migration notes: add the compose-side per-field change (whole
  response null → per-field) and the operation-scope fail-stop change
- Contract scenario 4/5 updated to match
- tasks.md: review-fix record (P1-P3 + polish + new public APIs +
  the known multi-operation same-name-group boundary, documented)

Co-Authored-By: Claude <noreply@anthropic.com>
@allmonday
allmonday merged commit e0ab3b2 into master Sep 2, 2026
6 checks passed
allmonday added a commit that referenced this pull request Sep 2, 2026
Version bump for PR #141 (issue #140): method-level alias support on
queries and mutations, three-state mutation feedback with
operation-scope fail-stop, federation wire gate, and the review fixes
(per-field query errors, response-key error paths, cancellation
propagation).
Updates docs/changelog.md, pyproject.toml, uv.lock.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

Mutation fields with aliases are silently collapsed — only the last one executes

1 participant