diff --git a/.specify/feature.json b/.specify/feature.json index 06d5b9a..11820cb 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1 @@ -{ - "feature_directory": "specs/022-voyager-composed-clusters" -} +{"feature_directory": "specs/023-gql-alias-support"} diff --git a/docs/guide/graphql-aliases.md b/docs/guide/graphql-aliases.md new file mode 100644 index 0000000..763476b --- /dev/null +++ b/docs/guide/graphql-aliases.md @@ -0,0 +1,68 @@ +# GraphQL 别名(Alias)支持 + +自 specs/023 起,nexusx 的两条 GraphQL 查询路径(UseCase compose 与 entity-first)均支持**方法级字段别名**:一次请求中对同一个方法发起多次调用、各自携带参数,响应按别名分组。 + +## 快速示例 + +```graphql +query { TaskService { + high: list_tasks(priority: "high") { id title } + low: list_tasks(priority: "low") { id } +} } +``` + +两个别名是两次独立调用:`data.TaskService.high` 与 `data.TaskService.low` 各自携带对应参数的结果,子字段投影也各自独立。 + +批量写同样支持(串行、按声明顺序): + +```graphql +mutation { MindmapService { + n1: add_node(content: "one") { display_id } + n2: add_node(content: "two") { display_id } + n3: add_node(content: "three") { display_id } +} } +``` + +## 行为要点 + +| 场景 | 行为 | +|---|---| +| query 同方法不同参数 | 各自独立执行,响应键 = 别名 | +| query 某别名失败 | 该别名键为 `null` + `QUERY_FAILED`(携带 `extensions.service_method` 定位失败方法),其余别名不受影响 | +| query/mutation 同方法同参数 | 仍逐个执行,**不做方法级去重**;联邦场景下同选择同参数命中加载器缓存,同一节点只发一次 member 请求 | +| mutation 部分失败 | 已成功的结果保留;失败键为 `null` + `MUTATION_FAILED`(entity-first 为 `RESOLVER_ERROR`);其后按 fail-stop 跳过并标 `SKIPPED_PRIOR_FAILURE` | +| mutation 失败的 fail-stop 范围 | **operation 级**:跨 entity group / service 组传播——后续组的 mutation 一律跳过(对齐 GraphQL 串行语义);query 不受影响 | +| 响应键冲突(别名重复 / 别名撞字段名 / 无别名同名字段重复) | 报 `ALIAS_CONFLICT` 错误,不执行任何方法;**不做字段合并** | +| 嵌套字段级别名(返回值内部字段改名) | 明确报错(范围外) | +| 联邦远程关系字段层别名 | 明确报错(设计排除) | +| CLI `--select` 投影内的别名 | 明确报错 | + +完整行为矩阵见 [specs/023-gql-alias-support/contracts/graphql-alias-behavior.md](../specs/023-gql-alias-support/contracts/graphql-alias-behavior.md)。 + +## 联邦边界:别名到 mounter 为止 + +mounter 发往 member 的机造查询**永不含别名**——别名在 mounter 层被消化,member 端零改动、零别名负担。 + +## 错误响应结构 + +失败或被跳过的别名在 `data` 中对应键为 `null`,`errors` 条目携带 `path`(**响应键**,即别名——与 `data` 的键一致,按 GraphQL 规范可定位)与 `extensions.code`: + +```json +{ + "data": { "MindmapService": { "n1": { "display_id": 1 }, "n2": null, "n3": null } }, + "errors": [ + { "message": "...", "path": ["MindmapService", "n2"], "extensions": { "code": "MUTATION_FAILED", "service_method": "MindmapService.add_node" } }, + { "message": "Skipped 'n3' because a prior mutation failed", "path": ["MindmapService", "n3"], "extensions": { "code": "SKIPPED_PRIOR_FAILURE" } } + ] +} +``` + +compose 路径 query 失败的码为 `QUERY_FAILED`(同样携带 `service_method`);entity-first 路径方法异常的码为 `RESOLVER_ERROR`。 + +## 迁移说明(自 6.1.x 升级) + +- 同名字段不再"后者覆盖前者"——此前被静默丢弃的重复选择现在会得到明确错误 +- compose 路径中,某个 query 方法的失败不再作废整个响应:失败方法自己的键为 `null` + `QUERY_FAILED`,其余方法结果保留 +- entity-first 路径中,一个方法的异常不再作废整个实体分组:失败方法自己的键为 `null`,兄弟结果保留 +- mutation 失败后的跳过范围是整个 operation(跨组传播),而非仅当前分组 +- `QueryParser.validate_no_aliases()` 保留原语义,但 nexusx 内部不再调用——需要禁用别名的自定义防线可继续使用 diff --git a/specs/023-gql-alias-support/checklists/requirements.md b/specs/023-gql-alias-support/checklists/requirements.md new file mode 100644 index 0000000..b8cf422 --- /dev/null +++ b/specs/023-gql-alias-support/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: GraphQL Alias 支持(修复静默折叠) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders(库调用方视角;mounter/member/GraphQL 为项目领域语言而非实现细节) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain(两个待拍板决策均采用评估建议默认并记入 Assumptions,可在 /speckit-clarify 复核) +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded(FR-009 明确设计排除项:联邦远程关系字段层/嵌套字段级/CLI 投影别名) +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows(P1 止血 → P2 查询侧+联邦边界 → P3 mutation 侧) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification(文件行号等细节留在 note-tool id 44 评估,spec 只保留行为契约) + +## Notes + +- 校验通过(第 1 轮,0 失败项) +- FR-005/FR-006 的"独立反馈 + fail-stop"为评估建议默认,若用户在 clarify 阶段选择 graphql-core 式严格 fail-fast,需同步修改 US3 场景 2 与 SC-003 +- 与 AI mutation 安全分层方案(note-tool id 42)的联动点已划出范围(Assumptions 第 2 条),避免 spec 间耦合 diff --git a/specs/023-gql-alias-support/contracts/graphql-alias-behavior.md b/specs/023-gql-alias-support/contracts/graphql-alias-behavior.md new file mode 100644 index 0000000..1e8c895 --- /dev/null +++ b/specs/023-gql-alias-support/contracts/graphql-alias-behavior.md @@ -0,0 +1,34 @@ +# Contract: GraphQL Alias 行为矩阵(specs/023) + +对外行为契约——两条查询路径(compose / entity-first)统一适用。错误结构见 [data-model.md §2](../data-model.md)。 + +## 行为矩阵 + +| # | 场景 | 行为 | 依据 | +|---|---|---|---| +| 1 | query 方法级别名,同方法不同参数(`high: list(p: 高)` + `low: list(p: 低)`) | 两次独立执行(参数各自正确),响应键 = 别名,各别名按自己声明的子字段投影 | FR-002/003 | +| 2 | query 方法级别名,同方法同参数 | 仍逐个独立调用(**不做方法级结果去重**);联邦场景同 selection 同参数命中同一加载器 key 缓存,同一节点对 member 只发一次请求 | FR-011 | +| 3 | mutation 方法级别名 ×N | 按声明顺序串行**全部执行**(N 个别名 = N 次副作用,禁止执行级去重),响应键 = 别名 | FR-004/011 | +| 4 | mutation 第 k 个失败 | 前 k-1 个已成功结果保留;第 k 个键为 null + `MUTATION_FAILED`;其后全部 null + `SKIPPED_PRIOR_FAILURE`(fail-stop 为 **operation 级**:跨 entity group / service 组传播,后续组的 mutation 一律跳过;query 不受 abort 标志影响——review 后敲定,对齐 GraphQL operation 级串行语义) | FR-005/006 | +| 5 | query 某别名失败 | 该别名 null + 错误条目(compose 错误码 `QUERY_FAILED`,携带 `extensions.service_method`),其余别名不受影响(无 fail-stop) | US2 场景 3 | +| 6 | 同层响应键重复(别名重复 / 别名撞字段名 / 无别名同名字段重复) | 报错 `ALIAS_CONFLICT`,不执行任何方法;**不做字段合并** | FR-007 | +| 7 | 顶层 entity group / service 名重复 | 同 #6,报错 | FR-007 | +| 8 | 嵌套字段级别名(返回值内部字段改名,如 `t1: title`) | 报错"不支持"(本期范围外,明确报错非静默) | FR-009 | +| 9 | 联邦远程关系字段层别名(β 嵌套远程字段) | 报错"不支持"(**设计排除项**,非待办) | FR-009 | +| 10 | CLI `--select` 投影语法内的别名 | 报错"不支持" | FR-009 | +| 11 | `enable_mutation=False` 收到别名 mutation | 与单次 mutation 一致地被拒绝(能力开关与调用次数正交) | FR-004 | +| 12 | mounter 发往 member 的机造查询 | **永不含别名**(member 零改动) | FR-008 | + +## 阶段交付语义(渐进放宽) + +| 阶段 | 行为变化 | +|---|---| +| A(止血) | compose 入口对**一切**别名报错"不支持"(此时 #1-#5 尚未实现,先保证不静默) | +| B1a | #1、#2、#5-#10、#12 生效(query 侧 + 全部报错路径 + 联邦闸门);mutation 别名仍按 A 报错 | +| B1b | #3、#4、#11 生效(mutation 侧) | + +## 公共 API 兼容 + +- `FieldSelection.sub_fields`:类型签名不变(`dict[str, FieldSelection]`),key 含义变更为响应键——changelog 显著说明,minor 6.2.0 +- `QueryParser.validate_no_aliases()`:**保留原语义**(检测并拒绝别名),供外部自定义防线使用;库内调用移除 +- entity-first mutation 异常响应:从「整组 null」变为「逐字段三态」——行为改进,changelog 说明 diff --git a/specs/023-gql-alias-support/data-model.md b/specs/023-gql-alias-support/data-model.md new file mode 100644 index 0000000..1eeaf04 --- /dev/null +++ b/specs/023-gql-alias-support/data-model.md @@ -0,0 +1,75 @@ +# Data Model: GraphQL Alias 支持(specs/023) + +Phase 1 产出。本 feature 无新增持久化实体;数据模型变更集中在**查询解析树的键语义**与**响应信封的错误结构**两处契约。 + +## 1. FieldSelection(既有结构,键语义变更) + +```text +FieldSelection +├── name: str # 查找键(不变)——方法/字段解析一律用它 +├── alias: str | None # 别名(不变,既有字段) +├── arguments: dict # 参数(不变) +└── sub_fields: dict[str, FieldSelection] + # ⚠ 语义变更:key = alias or name(响应键) + # 旧语义:key = name(同名字段互相覆盖 → Issue #140 根因) +``` + +### 键的两种用途(本次变更的核心区分) + +| 用途 | 用哪个 | 消费方示例 | +|---|---|---| +| **查找**(这个字段是什么/方法在哪) | `FieldSelection.name` | 方法表查找、DTO 字段解析、联邦渲染 | +| **响应组装**(结果挂在哪个键下) | `sub_fields` 的 dict key | `entity_data[key]`、`results[key]` | + +### 同层响应键冲突检测(新增不变量) + +`_parse_selection_set` 构建 `sub_fields` 时,同层出现重复 key 即抛错(三种形态等价处理): + +- `a: f` 出现两次(别名重复) +- `a: f` 与 `a: g`(别名撞其他字段名) +- `f { x }` 与 `f { y }`(无别名同名字段重复——不做字段合并,clarify Q2 决策) + +顶层(operation 的 entity group / service 名)同名字段重复同族处理。 + +## 2. 响应信封(mutation 三态,entity-first 与 compose 统一) + +```text +{ + "data": { + "": { + "<响应键>": <结果> # 已成功:正常值 + # 失败/跳过:null + } + }, + "errors": [ + { + "message": "<人类可读>", + "path": ["", "<响应键>"], + "extensions": { "code": "<错误码>" } + } + ] +} +``` + +### 错误码(extensions.code) + +| code | 含义 | 触发场景 | +|---|---|---| +| `ALIAS_CONFLICT` | 同层响应键重复 | parser 冲突检测(D2) | +| `MUTATION_FAILED` | 该调用自身抛异常 | mutation 串行执行中(FR-005) | +| `SKIPPED_PRIOR_FAILURE` | 因前序失败未执行 | fail-stop 跳过(FR-006) | +| `RESOLVER_ERROR` | 既有码,保留 | 查询解析器异常(沿用) | + +### 不变量 + +- 查询中出现过的响应键,在 `data` 中必存在(成功为值,失败/跳过为 null)——D3 决策 +- mutation 按声明顺序串行;首个 `MUTATION_FAILED` 之后的所有调用为 `SKIPPED_PRIOR_FAILURE` +- query 无 fail-stop:各别名独立成败(失败别名 null + 错误条目,成功别名正常返回) + +## 3. 联邦 wire 契约(不变式,非新结构) + +mounter 发往 member 的机造查询**永不含别名**——由渲染出口用 `FieldSelection.name` 重建保证(D6)。member 端数据结构零变更。 + +## 4. 状态迁移 + +无持久化状态迁移。行为状态变化仅一处:entity-first 方法异常的响应状态从「整组 → null」迁移为「失败字段 → null + errors、其余字段保留」(D4)。 diff --git a/specs/023-gql-alias-support/plan.md b/specs/023-gql-alias-support/plan.md new file mode 100644 index 0000000..68d8ed3 --- /dev/null +++ b/specs/023-gql-alias-support/plan.md @@ -0,0 +1,78 @@ +# Implementation Plan: GraphQL Alias 支持(修复静默折叠) + +**Branch**: `023-gql-alias-support` | **Date**: 2026-08-30 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/023-gql-alias-support/spec.md` + +## Summary + +修复 Issue #140(mutation 别名静默折叠)并为 query/mutation 的**方法级字段**实现 alias 支持。核心技术变更:`FieldSelection.sub_fields` 的 key 语义从 `field_name` 改为 `alias or field_name`(响应键),查找一律改走 `FieldSelection.name`(原始名);6 处下游同步,其中 federation 渲染出口(`remote_loader._render_selection`)用 `child.name` 渲染,保证 wire 永不含 alias(member 零改动)。mutation 部分失败改为逐调用三态反馈(已成功保留 / 失败独立报错 / 跳过标注),entity-first 的"整组作废"语义同步移除。分三阶段交付:A 止血(compose 入口 fail loudly)→ B1a query 侧 → B1b mutation 侧。 + +## Technical Context + +**Language/Version**: Python 3.12(uv 管理) + +**Primary Dependencies**: graphql-core(AST 解析)、pydantic v2(投影/动态模型)、aiodataloader(批量加载)、FastAPI + FastMCP(HTTP/MCP 面) + +**Storage**: N/A(纯查询层特性,无持久化变更) + +**Testing**: pytest(pytest-asyncio);全量基线 1505+ 用例,13 个测试文件直接触及 `QueryParser`/`sub_fields` + +**Target Platform**: Python 库,随宿主部署(无独立运行时) + +**Project Type**: library + +**Performance Goals**: 别名解析为每选择集 O(n) 一次字典插入 + 冲突检测,无新增性能目标;联邦同参同选共享 DataLoader key 缓存为既有机制(`get_loader` 的 type_key/params_key 拆实例),不新增缓存 + +**Constraints**: 公共 API 兼容(次版本 6.2.0 + changelog 显著说明);member 服务零改动;`validate_no_aliases` 保留原语义供外部使用,仅移除库内调用 + +**Scale/Scope**: 6 处源码下游(其中 5 处小改、1 处中改);`core_builder`/`response_builder` 本期不动(B2 范围外,清单记录) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +`.specify/memory/constitution.md` 为未填充的占位模板(无已批准的原则/门禁),无适用约束。**Gate 通过**。 + +## Project Structure + +### Documentation (this feature) + +```text +specs/023-gql-alias-support/ +├── plan.md # 本文件(/speckit-plan 产出) +├── research.md # Phase 0 产出:8 项设计决策记录 +├── data-model.md # Phase 1 产出:FieldSelection 键语义契约 + 三态响应信封 +├── quickstart.md # Phase 1 产出:6 个端到端验证场景 +├── contracts/ # Phase 1 产出:对外行为契约 +│ └── graphql-alias-behavior.md +└── tasks.md # Phase 2 产出(/speckit-tasks,本命令不创建) +``` + +### Source Code (repository root) + +```text +src/nexusx/ +├── query_parser.py # [阶段 A+B1a] key 语义变更 + 同层响应键冲突检测 +├── use_case/compose_executor.py # [阶段 A] 入口校验;[B1a/B1b] 方法查找改 sel.name、 +│ # 响应 key 用 dict key、mutation 三态反馈 +├── execution/query_executor.py # [B1a/B1b] field_sel 按 alias or name 查找、响应 key +│ # 同上、移除 group_failed 整组作废 +├── federation/remote_loader.py # [B1a] _render_selection 用 child.name(边界闸门) +├── core_builder.py # 本期不动(B2:查找名/输出名分离时才改) +└── response_builder.py # 本期不动(dormant,清单记录) + +tests/ +├── test_query_parser.py # key 语义、冲突检测(含无别名同名字段重复) +├── test_compose_executor.py # query 扇出、mutation 三态、同参不去重 +├── test_query_executor.py # entity-first 对齐用例、组级 null 移除回归 +└── test_federation_*.py # wire 无 alias 断言(β/γ/分页矩阵) +``` + +**Structure Decision**: 单库结构(`src/nexusx/` + `tests/`),沿用仓库现状;本 feature 不新增模块,全部为既有文件的行为变更,新建测试用例落在对应既有测试文件。 + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +无违规,不适用。 diff --git a/specs/023-gql-alias-support/quickstart.md b/specs/023-gql-alias-support/quickstart.md new file mode 100644 index 0000000..5356949 --- /dev/null +++ b/specs/023-gql-alias-support/quickstart.md @@ -0,0 +1,96 @@ +# Quickstart: GraphQL Alias 支持(specs/023) + +Phase 1 产出。端到端验证场景,每个场景独立可跑,证明 feature 按契约工作。行为预期见 [contracts/graphql-alias-behavior.md](./contracts/graphql-alias-behavior.md),错误结构见 [data-model.md §2](./data-model.md)。 + +## 前置 + +```bash +uv sync --all-extras +uv run pytest tests/ -x -q # 基线:全量通过(1500+) +``` + +## 场景 1:Issue #140 原始复现(阶段 B1b 后应通过) + +```bash +uv run python - <<'EOF' +from nexusx.query_parser import QueryParser +from graphql import parse + +q = """mutation { MyService { + a1: add_node(content: "one") { display_id } + a2: add_node(content: "two") { display_id } + a3: add_node(content: "three") { display_id } +} }""" + +doc = parse(q) +svc = QueryParser().parse_document(doc)["MyService"] +assert len(svc.sub_fields) == 3, f"期望 3 个响应键,实际 {len(svc.sub_fields)}" +assert list(svc.sub_fields) == ["a1", "a2", "a3"] +print("✓ 解析层保住 3 个别名(6.1.2 此处只剩 1 个)") +EOF +``` + +端到端(compose MCP 面):同形状查询经 `compose_query` 执行后,3 个节点全部创建、`data.MyService` 含 `a1/a2/a3` 三个键、`errors == []`。 + +## 场景 2:query 扇出(阶段 B1a 后应通过) + +```bash +uv run python - <<'EOF' +from nexusx.query_parser import QueryParser + +q = """query { TaskService { + high: list_tasks(priority: "high") { id title } + low: list_tasks(priority: "low") { id } +} }""" +svc = QueryParser().parse(q)["TaskService"] +assert svc.sub_fields["high"].name == "list_tasks" # 查找键是原始名 +assert svc.sub_fields["high"].arguments == {"priority": "high"} +assert svc.sub_fields["low"].arguments == {"priority": "low"} +assert set(svc.sub_fields["high"].sub_fields) == {"id", "title"} +assert set(svc.sub_fields["low"].sub_fields) == {"id"} +print("✓ 两个别名各自持参数与投影,互不覆盖") +EOF +``` + +端到端:两次查询都执行,`data.TaskService.high/low` 各自返回对应参数的结果。 + +## 场景 3:mutation 部分失败三态(阶段 B1b 后应通过) + +发送 3 个别名 mutation,第 2 个制造异常(如违反约束的参数): + +- `data.Svc.a1` = 正常结果(**已成功的结果不丢**——这是对 entity-first 整组作废语义的变更验证点) +- `data.Svc.a2` = `null`,`errors` 含 `path: ["Svc","a2"]`、`extensions.code: "MUTATION_FAILED"` +- `data.Svc.a3` = `null`,`errors` 含 `extensions.code: "SKIPPED_PRIOR_FAILURE"` + +## 场景 4:响应键冲突报错(阶段 B1a 后应通过) + +- `a: f(x: 1)` 写两遍 → `ALIAS_CONFLICT`,无方法执行 +- `f { x }` 与 `f { y }`(无别名同名字段重复)→ `ALIAS_CONFLICT`(不做合并) + +## 场景 5:联邦 wire 无别名(阶段 B1a 后应通过) + +联邦测试矩阵(β 物化 / γ DTO / 分页)中以方法级别名查询 mounter,在 FakeTransport/HTTP 断言层捕获发往 member 的查询字符串: + +```python +assert ":" not in captured_query_fields # 机造查询字段区无别名标记 +``` + +member 端代码零改动即全部矩阵通过。 + +## 场景 6:全量回归(每阶段交付前) + +```bash +uv run pytest tests/ -q # 零回归(基线不下降) +ruff check src/ # CI lint 范围 +``` + +## 验收对照 + +| 场景 | 对应 SC | 阶段 | +|---|---|---| +| 1 | SC-006(6 个 add_node 全建) | B1b | +| 2 | SC-001(N 执行 N 分组) | B1a | +| 3 | SC-003(已成功保留率 100%) | B1b | +| 4 | SC-002(0 静默丢弃) | B1a | +| 5 | SC-004(wire 别名数 = 0) | B1a | +| 6 | SC-005(零回归) | 每阶段 | diff --git a/specs/023-gql-alias-support/research.md b/specs/023-gql-alias-support/research.md new file mode 100644 index 0000000..3aefb36 --- /dev/null +++ b/specs/023-gql-alias-support/research.md @@ -0,0 +1,71 @@ +# Research: GraphQL Alias 支持(specs/023) + +Phase 0 产出。Spec 无遗留 NEEDS CLARIFICATION(4 项决策已在 clarify 确认),本文记录实现层设计决策及其备选方案,全部结论来自对当前代码(6.1.2,commit 5d03777 之后)的直接验证。 + +## D1: `sub_fields` 的 key 语义——`alias or field_name`,查找一律走 `FieldSelection.name` + +- **Decision**: 字典 key 即响应键(`alias or field_name`);方法/字段解析查找一律用 `FieldSelection.name`(原始名)。 +- **Rationale**: 最小传染——迭代式消费方(compose executor、build_model)改为 `sel.name` 查找即可,按 key 取响应的路径天然正确;dict 保序保证声明顺序(mutation 串行序)。 +- **Alternatives**: + - (a) `sub_fields` 改 `list[FieldSelection]`——语义最彻底,但所有 `.get(name)`/`.items()` 消费全变线性扫描或需重建索引,13 个测试文件大范围重写, rejected; + - (b) 双 dict(by_name + by_key)——内存翻倍、两份结构一致性维护,仅省一次 `name` 属性访问,rejected。 + +## D2: 同层响应键冲突检测放 parser 层 + +- **Decision**: 在 `_parse_selection_set` 构建 dict 时检测重复 key,抛出带位置的错误(含别名重复、别名撞字段名、无别名同名字段重复三种形态,见 clarify Q2/Q4)。 +- **Rationale**: fail earliest——两条执行路径(compose/entity-first)共享同一拦截点,错误在任何方法调用前产生。 +- **Alternatives**: executor 层各自检测——两份实现、拦截时机更晚(部分方法可能已执行),rejected。 + +## D3: mutation 三态反馈的响应呈现 + +- **Decision**: 失败或被跳过的别名在 `data` 中对应键为 `null`;`errors` 条目带 `path`(定位到别名)与 `extensions.code` 区分三态:`MUTATION_FAILED`(该调用抛异常)/ `SKIPPED_PRIOR_FAILURE`(因前序失败未执行)。已成功的别名正常携带结果。 +- **Rationale**: 贴 GraphQL 规范的 data/errors 分工(失败字段在 data 为 null),`extensions.code` 是生态惯用的机器可读标注,MCP agent 可据此精确重试。 +- **Alternatives**: 失败键从 data 中整个省略——破坏"查询里出现过的键必在 data 中"的直觉契约,agent 无法区分"没执行"与"执行了但序列化丢键",rejected。 + +## D4: entity-first 移除 `group_failed` 整组作废 + +- **Decision**: `_execute_entity_group` 的 `group_failed → return None` 机制改为逐字段收集(失败字段 null + errors 条目),与 compose 路径对齐(clarify Q1)。 +- **Rationale**: FR-005 要求两路径一致;组级作废在批量场景抹掉已成功副作用,正是 Issue #140 报告的危险模式。 +- **Alternatives**: 仅 compose 改、entity-first 保留组级 null——两路径行为分叉需永久文档化,且存量组级语义本是 v1 简化(docstring 自述),rejected。 +- **回归风险**: 依赖整组 null 的既有测试需同步更新;串行执行顺序(DataLoader store-then-read 去重不变量)不受影响,仅错误粒度变化。 + +## D5: `validate_no_aliases` 的退场方式——保留原语义,仅移除库内调用 + +- **Decision**: 函数保留(仍拒绝 alias,供外部自定义防线使用),`handler.py:324` 的内部调用移除;docstring 更新为"可选的用户侧校验工具"。 +- **Rationale**: 公共 API 不破坏(feedback 公约);它的语义(检测并拒绝)本身没有过时,过时的只是"nexusx 自身不支持 alias"这一前提。 +- **Alternatives**: 改为 no-op + deprecation——语义突变且无必要(外部用它的人仍想要拒绝行为),rejected。 + +## D6: federation 渲染出口剥 alias(边界闸门) + +- **Decision**: `_render_selection`(`remote_loader.py:247`)改用 `child.name` 渲染字段名,不透传 dict key;`build_gql_query` 的其余部分不变。member 端零改动。 +- **Rationale**: 渲染出口唯一(β `_RemoteLoader` / γ `_DtoRemoteLoader` / `_PaginatedRemoteLoader` 三类 loader 全走它);spec FR-008 的 wire 边界在此一处置防。 +- **Alternatives**: 在 parser 层为 federation 单独建一棵"剥 alias 的树"——多一份结构副本、两处维护,rejected(机造查询本就由 `child.name` 重建,天然无别名)。 + +## D7: 同方法同参数不去重(FR-011) + +- **Decision**: mutation 逐个独立执行(禁执行级去重);query 亦逐个独立调用,性能由数据层既有 DataLoader 批量/缓存提供(同 selection 同参数 → 同 type_key → 同实例 → key 缓存命中,同一节点只发一次 member 请求)。 +- **Rationale**: 去重隐含方法纯度假设;graphql-core/Apollo 均不做字段级去重;"正确性在方法层、性能在数据层"分层清晰。 +- **Alternatives**: request 级 memoization(按 (method, args) 缓存)——留作将来透明优化,不改契约,v1 不做。 + +## D8: 版本策略——minor 6.2.0 + +- **Decision**: 次版本递增 + changelog 显著条目(说明 key 语义变更与 entity-first 错误粒度变更)。 +- **Rationale**: `sub_fields` 类型签名不变仅 key 含义变(语义 breaking),但存量不存在正确使用别名的代码(此前要么被拒要么被吞),实际兼容影响为零;entity-first 错误粒度变更是行为改进方向(null 组 → 精确三态)。 +- **Alternatives**: major 7.0.0——过度保守,无真实破坏面,rejected(spec Assumptions 已确认)。 + +## 验证记录(决策依据的代码事实) + +| 事实 | 位置 | 验证方式 | +|---|---|---| +| dict 覆盖根因 | `query_parser.py:121` | 静态阅读 + Issue #140 复现 | +| entity-first 放开后 N 次全执行、key 覆盖、投影串用 | `query_executor.py:183/207/268` | monkeypatch 实验(2026-08-30,CALL_LOG 证据) | +| 关系解析缓存实例级隔离 | `query_executor.py:57` `(id(entity), field)` | 静态阅读 | +| federation loader 按 selection/params 拆实例 | `remote_loader.py:100-111`(type_key + force_split)、`registry.py:728-766`(params_key) | 静态阅读,注释明言防并发竞争 | +| wire 纯读无 mutation | `remote_loader.py` 全文 grep | 零命中 | +| compose 无 alias 校验 | `compose_executor.py:107` | 静态阅读 + Issue 报告 | + +## 实现期新增发现(D6 修正) + +- **`FieldSelection.name` 在手工构建树上语义不统一**:`Resolver._build_nested_selection`(resolver.py:1352)把 `name` 用作**目标类型名**(如 `MTComment`),而 parser 生成的树上 `name` 是原始字段名。联邦渲染闸门最初无条件用 `child.name` 渲染,导致机造查询输出类型名、member 报 unknown field(deep_chain/materialized 两测试抓出)。**修正规则**(`_wire_render_name`):仅 `alias is not None` 的节点用 `name` 渲染(parser 树保证其为原始字段名),无 alias 节点一律渲染 key——兼容手工树。 +- **entity-first 的序列化走 `response_builder` 而非 `core_builder.build_model`**(评估阶段"response_builder dormant"的判断有误——它在 entity-first `_serialize` 路径上活跃)。嵌套别名检测因此落在 `_validate_method_selection`(执行前、有 path),`core_builder` 的检测作为 compose 投影路径的兜底。 +- **query 失败隔离语义**:执行期异常(方法体抛错、参数 coerce 失败、FromContext 缺失)统一 per-field(键 null + `QUERY_FAILED`);规划期错误(unknown service/method、enable_mutation、parse、键冲突)保持 fail-fast——贴 GraphQL 的 field error 语义,也是 5 个存量断言更新的依据。 diff --git a/specs/023-gql-alias-support/spec.md b/specs/023-gql-alias-support/spec.md new file mode 100644 index 0000000..7b666e6 --- /dev/null +++ b/specs/023-gql-alias-support/spec.md @@ -0,0 +1,130 @@ +# Feature Specification: GraphQL Alias 支持(修复静默折叠) + +**Feature Branch**: `023-gql-alias-support` + +**Created**: 2026-08-30 + +**Status**: Draft + +**Input**: User description: "GraphQL Alias 支持(Issue #140):为 query 和 mutation 的方法级字段实现 alias 支持,修复静默折叠 bug…" + +**参考**: Issue #140(KLR-Pattern/nexusx);完整评估存 note-tool id 44(tag: 技术/编程/nexusx)。 + +## Clarifications + +### Session 2026-08-30 + +- Q: mutation 部分失败的反馈粒度,两条路径是否统一改为逐调用反馈? → A: 统一逐调用反馈(entity-first 现有"任一方法异常整组作废"的语义同步修改,两路径行为一致) +- Q: 同名字段(无别名、参数一致)重复出现时如何处理? → A: 一律报错(不实现 GraphQL 规范的字段合并;规则简化为"同层级响应键必须唯一") +- Q: 联邦路径下同方法别名调不同参数如何处理? → A: 两个别名即两次独立方法调用;联邦侧零改动——既有隔离机制天然覆盖(loader 按 selection 指纹 type_key 与分页参数 params_key 拆实例,`_remote_selection` 侧信道并发安全) +- Q: 两个别名对应的方法与参数完全相同时如何处理? → A: 按操作类型分治——mutation 必须逐个独立执行(禁止执行级去重,否则副作用静默丢失);query v1 也逐个独立调用(不做方法级结果去重),性能由数据加载层的既有 DataLoader 批量/缓存机制自然提供(同 selection 同参数命中同一实例的 key 缓存,同一节点只发一次 member 请求) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 不支持的别名必须明确报错,而不是悄悄丢数据(Priority: P1) + +MCP agent 或前端在 compose 查询路径(UseCase GraphQL MCP 面)里使用了别名(例如 `a1: add_node(...)`、`a2: add_node(...)`)。在今天,只有最后一次调用被执行,其余被静默丢弃,响应中没有任何错误——调用方以为 N 次都成功了(真实案例:协作脑图应用发 6 个 add_node 只建了 1 个节点)。本故事要求:凡当前不支持别名的场景,必须返回带位置信息的 GraphQL 错误,做到"要么正确支持,要么大声拒绝",绝不允许静默丢弃。 + +**Why this priority**: 这是数据正确性问题(静默丢写/丢查),影响所有现存版本的调用方,且工作量最小(约半天),可以先于一切能力扩展独立发布止血。 + +**Independent Test**: 在 compose 查询入口发送一个带别名的 mutation,断言响应含明确错误信息且不执行任何方法调用;可独立发布并独立验证。 + +**Acceptance Scenarios**: + +1. **Given** compose 查询路径收到含别名的查询,**When** 执行,**Then** 响应 `errors` 含"别名不受支持"类错误及可定位的位置信息,`data` 为空,且没有任何服务方法被调用 +2. **Given** entity-first 路径(HTTP GraphQL 面)收到含别名的查询(现状已显式拒绝),**When** 执行,**Then** 行为保持不变(回归保护) +3. **Given** 同一查询只在支持的层(后续故事交付的能力)使用别名,**When** 执行,**Then** 不被本故事的校验拦截(校验只拦"明确不支持"的层) + +--- + +### User Story 2 - 查询侧方法级别名:一次请求拿多组结果(Priority: P2) + +调用方需要在一次 GraphQL 请求中对同一个查询方法传不同参数并各拿一组结果:`high: list_tasks(priority: "高")`、`low: list_tasks(priority: "低")`。这能让 MCP agent 用一次往返完成多组数据获取,直接减少往返次数与上下文消耗(与 selection"按需返回、省 context"的定位同向)。要求:两次调用都真实执行、参数各自正确、响应按别名分组返回。 + +**Why this priority**: 别名的第一大用途(同方法不同参数的查询扇出);查询无副作用、失败重试无害、无任何待决策依赖,可在 P1 之后立即动工。 + +**Independent Test**: 对任一已有的查询方法发送两个不同参数的别名调用,断言两组结果都返回且互不串数据;不依赖 mutation 侧能力即可独立交付与演示。 + +**Acceptance Scenarios**: + +1. **Given** compose 查询路径收到 `a: 查询方法(参数X)` 与 `b: 同一查询方法(参数Y)`,**When** 执行,**Then** 方法被调用两次(各自参数正确),响应 `data` 含 `a`、`b` 两个键,各自携带对应参数的结果 +2. **Given** entity-first 路径收到同样形态的查询,**When** 执行,**Then** 行为一致(两次执行、按别名分组、各自投影字段正确——每个别名按自己声明的子字段集合返回) +3. **Given** 两个别名调用中一个因参数错误失败,**When** 执行,**Then** 失败的那个以错误呈现,成功的那个结果正常返回(查询可安全重试) +4. **Given** 两个别名的查询方法与参数完全相同(如 `a: list(limit:10)` 与 `b: list(limit:10)`),**When** 执行,**Then** 两个别名各自拿到结果(不做方法级结果去重);联邦场景下若二者的字段选择也相同,member 收到的重复请求次数为 0(同一节点命中加载器缓存,只发一次请求) + +--- + +### User Story 3 - Mutation 侧方法级别名:批量写与逐调用反馈(Priority: P3) + +调用方(典型为 MCP agent)需要在一个请求里对同一个 mutation 方法发起多次带别名的调用以完成批量写:`n1: add_node("一")` … `n6: add_node("六")`,一次往返创建 6 个节点并拿到 6 个各自的标识。要求:全部按声明顺序串行执行、响应按别名分组;**部分失败时,已成功调用的结果不丢失**——第 2 个失败不能把第 1 个已发生的写入从响应中抹掉,每个失败的调用有独立的错误条目。 + +**Why this priority**: 是 Issue #140 报告者的原始痛点,但涉及"部分失败怎么报"与后续 AI mutation 安全分层的联动(连发 N 次的风险语义),需在 P2 交付后按既定决策实施。 + +**Independent Test**: 发送 3 个别名的 add_node(其中第 2 个制造失败),断言节点 1 与 3 正常创建并在响应中按别名返回、节点 2 有独立错误条目;可独立于 P2 验证。 + +**Acceptance Scenarios**: + +1. **Given** 一个请求含 N 个同方法的别名 mutation,**When** 执行,**Then** 按声明顺序串行执行全部 N 次,响应 `data` 含 N 个按别名分组的结果(每个调用拿到自己的返回值,如各自的新建 id) +2. **Given** N 个别名 mutation 中第 k 个抛出异常,**When** 执行,**Then** 第 1..k-1 个已成功的结果保留在响应中,第 k 个以带位置信息的错误条目呈现;其后的调用按声明的停止策略处理(见 FR-006) +3. **Given** 应用以 `enable_mutation=False` 配置,**When** 收到别名 mutation 请求,**Then** 与单次 mutation 一致地被拒绝(能力开关与调用次数正交) + +--- + +### User Story 4 - 联邦边界:别名在组装方终结,绝不下发成员服务(Priority: P2,作为 US2/US3 的横切约束) + +联邦场景下,mounter(组装方)对外暴露的查询面同样具备 US2/US3 的别名能力;但 mounter 发往 member(成员服务)的机造查询**永远不含别名**——别名在 mounter 层被消化。member 端零改动、零别名负担。 + +**Why this priority**: 联邦 wire 契约的最小化是成员接入成本与测试矩阵规模的直接决定因素;该约束与 US2/US3 同批生效,避免能力上线后 wire 被意外污染。 + +**Independent Test**: 在联邦测试矩阵(β 物化路径、γ DTO 组合路径、分页路径)中发送含方法级别名的查询,断言 member 收到的查询字符串不含任何别名且联邦全链路结果正确。 + +**Acceptance Scenarios**: + +1. **Given** 用户对 mounter 发送含方法级别名的查询(γ 组合路径),**When** mounter 向 member 发起远程请求,**Then** 机造查询只含原始字段名,member 端无需任何别名能力即正确执行 +2. **Given** 联邦远程关系字段层(β 路径嵌套在关系里的远程字段)收到别名用法,**When** 执行,**Then** 返回明确的"不支持"错误(该层为设计排除项,见 FR-009),不发生静默丢弃或数据串扰 + +### Edge Cases + +- 同一别名重复出现(`a: f` 写两遍)、别名与其他字段名撞车、以及无别名的同名字段重复(`f { a } f { b }`)造成响应键冲突时,必须报错,不得静默去重,也不做字段合并 +- 顶层实体分组/服务名层面的同名字段重复(`{ A {...} A {...} }`)属同族问题,本期一并明确报错 +- 嵌套字段级别名(返回值内部字段改名,如 `t1: title`):本期明确报错,不静默、不部分支持(现状 compose 已是显式报错,保持) +- 一次请求中别名数量过多(如上百个)时的行为:不做人为上限,但错误信息与响应体量不得失控(错误信息有界) +- CLI `--select` 投影语法里的别名:与嵌套字段级同待遇(明确报错),本期不支持 +- 别名连发共享同一份请求上下文(FromContext):为合理默认,上下文消费型方法(如一次性令牌)的行为由方法实现方自负 + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: compose 查询路径 MUST 在执行前检测别名用法;凡落在"本期不支持"层级的别名,MUST 返回含位置信息的 GraphQL `errors`,且不得执行任何服务方法(不得静默丢弃) +- **FR-002**: 查询方法级别名——同一查询方法的 N 个不同参数的别名调用 MUST 全部执行(参数各自正确),响应 MUST 按别名分组;每个别名的子字段投影 MUST 按各自声明,不得串用 +- **FR-003**: entity-first 路径(HTTP GraphQL 面)的查询方法级别名行为 MUST 与 FR-002 一致(两次执行、按别名分组、各自投影正确) +- **FR-004**: mutation 方法级别名——同一 mutation 方法的 N 个别名调用 MUST 按声明顺序串行执行,响应 MUST 按别名分组;`enable_mutation=False` 的拒绝语义 MUST 与调用次数无关 +- **FR-005**: mutation 部分失败——第 k 个调用失败时,第 1..k-1 个已成功调用的结果 MUST 保留在响应中,第 k 个 MUST 产生带位置信息的独立错误条目。两条路径(compose 与 entity-first)MUST 行为一致;entity-first 现有"任一方法异常即整组作废"的语义为本次显式变更对象(存量行为依赖见 FR-010 的变更说明义务) +- **FR-006**: mutation 失败后的停止策略——失败之后的调用停止执行(fail-stop),且响应 MUST 明确区分"已成功(有结果)""失败(有错误)""未执行(因前序失败跳过)"三种状态 +- **FR-007**: 响应键冲突——同一层级的响应键重复 MUST 报错,不得静默去重,包括:别名重复(`a: f` 写两遍)、别名与其他字段名撞车、以及**无别名的同名字段重复**(不做 GraphQL 规范的字段合并,规则统一为"同层级响应键唯一");顶层分组/服务名同名字段重复同样 MUST 报错 +- **FR-008**: 联邦 wire 边界——mounter 发往 member 的机造查询 MUST NOT 含别名(成员服务零改动);联邦回归矩阵(β/γ/分页)MUST 全部通过 +- **FR-009**: 设计排除项——联邦远程关系字段层(β 嵌套远程字段)的别名用法 MUST 返回明确的"不支持"错误;嵌套字段级别名(返回值内部字段改名)与 CLI `--select` 投影别名同样 MUST 明确报错。以上均为设计边界而非待办 +- **FR-010**: 既有公共校验入口(别名拒绝函数)的语义变更 MUST 有弃用说明;版本策略为次版本号递增并在变更日志显著说明(无存量正确使用别名的代码,实际兼容性影响为零) +- **FR-011**: 同方法同参数的别名调用——mutation MUST 逐个独立执行,MUST NOT 做执行级去重或结果共享(副作用语义:N 个别名 = N 次副作用);query MUST 逐个独立调用(v1 不做方法级结果去重),数据加载层的既有批量与缓存机制自然生效(联邦场景下同 selection 同参数命中同一加载器实例的 key 缓存,同一节点对 member 只发一次请求) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 一次请求中同方法 N 个不同参数的别名调用,方法执行次数 = N、响应分组数 = N、结果与参数一一对应(自动化断言覆盖 compose 与 entity-first 两条路径) +- **SC-002**: 所有"不支持"的别名场景(嵌套层、联邦远程关系字段层、CLI 投影)100% 返回明确错误、0 例静默丢弃(负向用例全覆盖) +- **SC-003**: mutation 部分失败场景下,已成功调用的结果保留率 100%,失败调用均有带位置信息的独立错误条目 +- **SC-004**: 联邦测试矩阵中 member 收到的查询字符串别名出现次数 = 0 +- **SC-005**: 既有全量测试套件零回归(1500+ 用例基线不下降) +- **SC-006**: 真实场景验收——复现 Issue #140 的 6 个别名 add_node 请求,6 个节点全部创建且响应含 6 个按别名分组的返回值 + +## Assumptions + +- mutation 部分失败策略已在 clarify 确认为"每个调用独立反馈 + 失败后停止",且两路径统一(含 entity-first 整组作废语义的显式变更);偏离 graphql-core 的组级作废语义为有意决策 +- 连发 N 次的风险语义(如风险标注携带调用次数)不在本 spec 范围,与 AI mutation 安全分层方案(note-tool id 42)联动,届时以扩展点方式接入 +- 嵌套字段级别名(B2)不在本期范围:现状已为显式报错,无正确性缺口,留待有真实诉求时另立 spec +- GraphQL variables 维持不支持(决策边界:variables 永久不做、别名做——批量场景下二者的取舍已在评估记录) +- GraphQL 规范的"同名字段合法合并"(field merging)明确不做,同名字段重复一律报错——已在 clarify 确认(调用方实际不产生此类查询,规则简化优先) +- 版本走次版本号(6.2.0):别名在既有版本中要么被拒绝要么被吞,不存在正确使用它的存量代码,语义变更实际兼容性影响为零 +- 别名连发共享同一份请求上下文为合理默认,不引入按别名隔离上下文的机制 diff --git a/specs/023-gql-alias-support/tasks.md b/specs/023-gql-alias-support/tasks.md new file mode 100644 index 0000000..f178910 --- /dev/null +++ b/specs/023-gql-alias-support/tasks.md @@ -0,0 +1,201 @@ +# Tasks: GraphQL Alias 支持(修复静默折叠) + +**Input**: Design documents from `/specs/023-gql-alias-support/` + +**Prerequisites**: plan.md ✅、spec.md ✅、research.md ✅、data-model.md ✅、contracts/ ✅、quickstart.md ✅ + +**Tests**: 测试任务保留——spec 的 SC-001~006 全部为自动化断言,quickstart.md 即验证场景清单。各 story 测试任务前置(先写、确认失败、再实现)。 + +**Organization**: 按用户故事分组(US1 止血 → US2+US4 query 侧与联邦边界 → US3 mutation 侧),每阶段末有回归检查点,可独立交付。 + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: 可并行(不同文件、无未完成依赖) +- **[Story]**: 所属用户故事(US1/US2/US3/US4,映射 spec.md) +- 描述含精确文件路径 + +## Path Conventions + +单库结构:`src/nexusx/` + `tests/`(仓库根)。 + +--- + +## Phase 1: Setup(基线) + +**Purpose**: 确认回归基准,供 SC-005 对照 + +- [x] T001 运行全量基线:`uv run pytest tests/ -q` 全过并记录用例数;`ruff check src/` 通过(此数为零回归基准) + +--- + +## Phase 2: User Story 1 - 不支持的别名必须明确报错(Priority: P1)🎯 MVP + +**Goal**: compose 路径收到任何别名即返回带位置的错误,消灭静默丢弃;可独立发布止血(约半天) + +**Independent Test**: 对 compose 查询入口发送带别名的 query/mutation,断言 `errors` 非空且服务方法零调用(调用日志断言) + +### Tests for User Story 1(先写、确认失败) + +- [x] T002 [P] [US1] 在 tests/test_compose_executor.py 增加负向用例:别名 query / 别名 mutation / 嵌套别名三种形态 → 响应 `errors` 含"别名不支持"且 `extensions.code: "ALIAS_CONFLICT"`;用方法调用计数断言零执行 + +### Implementation for User Story 1 + +- [x] T003 [US1] 在 src/nexusx/use_case/compose_executor.py 的 `execute_compose_query` 步骤 2(introspection 拒绝)之后新增 AST 级别名检测:遍历 `document` 各层选择集,发现任何 alias → 返回 `_error_response`(错误信息标注"当前版本 compose 查询暂不支持别名",供后续阶段放宽时改写) + +**Checkpoint**: US1 独立可交付(PR 可先发);quickstart 场景 4 的 compose 半边应通过 + +--- + +## Phase 3: User Story 2 + 4 - query 侧方法级别名 & 联邦边界(Priority: P2) + +**Goal**: query 方法级别名全链路生效(两次执行、响应键=别名、投影隔离、冲突报错);联邦 wire 保证永不含别名(member 零改动) + +**Independent Test**: 发送 `high/low` 两个不同参数的别名查询,两条路径都返回两组正确结果;联邦矩阵中 member 收到的查询字符串零别名 + +### Tests for User Story 2 & 4(先写、确认失败) + +- [x] T004 [P] [US2] 在 tests/test_query_parser.py 增加:key=`alias or name` 语义用例(别名保留、参数/子字段隔离)+ 冲突三形态(别名重复、别名撞字段名、无别名同名字段重复)+ 顶层分组重复,均断言明确报错 +- [x] T005 [P] [US2] 在 tests/test_compose_executor.py 增加:query 扇出(两别名不同参数各自执行、结果分组正确)、同方法同参数不去重(FR-011 query 半边)、单别名失败不影响兄弟别名 +- [x] T006 [P] [US2] 在 tests/test_query_executor.py 增加:entity-first query 别名(执行次数=N、参数各自正确、响应键=别名、各别名按自己声明的子字段投影) +- [x] T007 [P] [US4] 在联邦测试矩阵(tests/test_federation_remote_loader.py 等)增加 wire 断言:FakeTransport/HTTP 捕获层断言发往 member 的查询字段区零别名(覆盖 β 物化、γ DTO、分页三路径) + +### Implementation for User Story 2 & 4 + +- [x] T008 [US2] 在 src/nexusx/query_parser.py 修改 `_parse_selection_set`:dict key 改为 `alias or field_name`;插入同层响应键冲突检测(重复 key 抛带位置错误);`parse_document` 顶层 operation 级同族检测 +- [x] T009 [US2] 在 src/nexusx/use_case/compose_executor.py 适配:`_execute_service_methods` 方法查找改用 `method_sel.name`(约 :185/:189),响应键沿用 dict key(约 :221/:225);mutation 别名保留 US1 的拒绝闸门(本阶段仅放行 query,见 contracts 阶段交付语义表) +- [x] T010 [P] [US2] 在 src/nexusx/execution/query_executor.py 适配:`field_sel` 查找改为 `alias or method_name`(约 :207),响应键 `entity_data` 改用别名(约 :268) +- [x] T011 [P] [US4] 在 src/nexusx/federation/remote_loader.py 修改 `_render_selection`(约 :247):渲染字段名一律用 `child.name`(原始字段名),不透传 dict key(联邦边界闸门,member 零改动) +- [x] T012 [P] [US2] 移除 src/nexusx/handler.py:324 的 `validate_no_aliases` 内部调用;更新 src/nexusx/query_parser.py:83 docstring(函数保留原语义,用途改为外部可选校验工具,见 research D5) +- [x] T013 [US2] 嵌套层与 CLI 显式报错:entity-first lenient 路径(src/nexusx/core_builder.py `build_model` 或其调用上层)检测嵌套别名并明确报错(替代静默 `Any`);src/nexusx/use_case/selection.py `parse_selection` 显式拒绝别名(CLI `--select`,给清晰错误而非"未知字段") +- [x] T014 [US2] 阶段检查点:全量 `uv run pytest tests/ -q` 零回归 + `ruff check src/`;quickstart 场景 2/4/5 通过 + +**Checkpoint**: query 侧 + 联邦边界完成;mutation 别名仍按 US1 报错(阶段语义正确) + +--- + +## Phase 4: User Story 3 - Mutation 侧方法级别名(Priority: P3) + +**Goal**: mutation 别名按声明顺序串行全执行、三态反馈(已成功保留/失败独立报错/跳过标注);entity-first 整组作废语义同步移除 + +**Independent Test**: 发送 3 个别名 add_node(第 2 个制造失败):节点 1、3 正常创建且按别名返回,节点 2 有 `MUTATION_FAILED` 条目;两路径行为一致 + +### Tests for User Story 3(先写、确认失败) + +- [x] T015 [P] [US3] 在 tests/test_compose_executor.py 增加:Issue #140 端到端复现(N 个别名 add_node 全建全返回,SC-006)、部分失败三态(失败键 null + `MUTATION_FAILED`、后续 `SKIPPED_PRIOR_FAILURE`)、同方法同参数 N 次 = N 次副作用(FR-011 mutation 半边)、`enable_mutation=False` 拒绝与调用次数正交 +- [x] T016 [P] [US3] 在 tests/test_query_executor.py 增加:entity-first 三态与 compose 一致用例;同步更新依赖"整组 null"的存量用例(D4 行为变更的回归面) + +### Implementation for User Story 3 + +- [x] T017 [US3] 在 src/nexusx/use_case/compose_executor.py 移除 T009 保留的 mutation 拒绝闸门;mutation 串行分支改为逐调用 try/except 三态收集(声明顺序不变,fail-stop:首个失败后跳过余下并标 `SKIPPED_PRIOR_FAILURE`) +- [x] T018 [P] [US3] 在 src/nexusx/execution/query_executor.py 移除 `group_failed` 整组作废(约 :290):改为逐字段三态(失败字段 null + `extensions.code`,兄弟结果保留);方法级 `enable_mutation` 校验逻辑不动 +- [x] T019 [US3] 阶段检查点:全量回归零失败 + `ruff check src/`;quickstart 场景 1/3 通过 + +**Checkpoint**: 全部用户故事完成;SC-001~006 可对照验收 + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +- [x] T020 [P] 文档:在 docs/ 增加 alias 行为说明(引用 specs/023-gql-alias-support/contracts/graphql-alias-behavior.md 的行为矩阵与阶段交付语义;docs-only 不计版本) +- [x] T021 最终验证:按 specs/023-gql-alias-support/quickstart.md 逐场景执行(6/6 通过);全量回归 + ruff;对照 spec.md SC-001~006 逐条勾验 + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1(Setup)**: 无依赖,立即开始 +- **Phase 2(US1)**: 依赖 Phase 1;**独立可交付**(止血 PR 可先合先发) +- **Phase 3(US2+US4)**: 依赖 Phase 2 的检测框架(T003 的检测代码被 T009 改造为阶段闸门);US4 的 T011 依赖 T008 的 key 语义 +- **Phase 4(US3)**: 依赖 Phase 3(T009 留下的 mutation 闸门在 T017 移除) +- **Phase 5(Polish)**: 依赖 Phase 4 完成 + +### User Story Dependencies + +- **US1 (P1)**: 无故事间依赖,MVP +- **US2+US4 (P2)**: 复用 US1 的检测入口;US4 是 US2 的横切约束(同一批交付、同一检查点 T014) +- **US3 (P3)**: 依赖 US2 的 key 语义与响应键改造;三态反馈的 compose 实现参考 US2 已就位的结构 + +### Within Each User Story + +- 测试先写、确认失败、再实现(各 Phase 测试组在前) +- parser 语义(T008)先行 → executor 适配(T009/T010)→ 边界与杂项(T011/T012/T013) +- 每阶段末检查点必须全绿再进下一阶段 + +### Parallel Opportunities + +- Phase 3 内:T004/T005/T006/T007 四个测试任务全部 [P](不同测试文件);T010/T011/T012 三项实现 [P](不同源码文件,均只依赖 T008) +- Phase 4 内:T015/T016 测试 [P];T018 与 T017 分别在两个执行器文件,实现层可 [P](T018 依赖 T009 已完成的 entity-first key 改造,不依赖 T017) +- Phase 5:T020 与 T021 可并行(文档 vs 验证) + +--- + +## Parallel Example: Phase 3 + +```bash +# 测试任务并行(四个不同测试文件): +Task: "T004 parser key 语义与冲突检测用例 in tests/test_query_parser.py" +Task: "T005 compose query 扇出用例 in tests/test_compose_executor.py" +Task: "T006 entity-first query 别名用例 in tests/test_query_executor.py" +Task: "T007 federation wire 零别名断言 in tests/test_federation_remote_loader.py" + +# T008 完成后,实现并行(三个不同源码文件): +Task: "T010 entity-first 执行器适配 in src/nexusx/execution/query_executor.py" +Task: "T011 federation 渲染闸门 in src/nexusx/federation/remote_loader.py" +Task: "T012 移除 handler 内部校验调用 in src/nexusx/handler.py" +``` + +--- + +## Implementation Strategy + +### MVP First(User Story 1 Only) + +1. T001 基线 → T002/T003 止血 +2. **STOP and VALIDATE**: compose 别名一律报错、零静默丢弃 +3. 可独立发版(6.1.3 patch 亦可,按"行为变更即发版"惯例定夺——止血是把静默丢改为报错,属行为修复) + +### Incremental Delivery + +1. Setup + US1 → 止血 PR(可先合) +2. US2+US4 → query 侧能力 + 联邦闸门(6.2.0 minor 主体) +3. US3 → mutation 侧补齐(同 6.2.0 或 6.2.x,取决于发布节奏) +4. Polish → 文档与最终验收 + +### Parallel Team Strategy + +单人顺序执行为默认(阶段闸门串行);若并行,Phase 3 的三个实现文件可分给多人,检查点 T014 汇合。 + +--- + +## Notes + +- [P] 任务 = 不同文件、无未完成依赖 +- 关键回归面:T016(存量"整组 null"用例)与 T007(federation 矩阵)是两处最大风险点,安排在检查点前 +- `core_builder.py` 的查找名/输出名分离(B2)与 `response_builder.py` 均不在本任务清单(spec FR-009 设计排除/范围外) +- commit 粒度建议:每阶段一组合(US1 一个、US2+US4 一个、US3 一个),检查点绿后提交 + +--- + +## Review 修复与增强(2026-09-01/02,超出原任务清单的记录) + +分支 review(全文存 note-tool id 46)发现的问题与增强,3+2 个 commit: + +### 修复(9f5c341 / 9d9f49f / b58f9f7) +- **P1 取消吞没**:compose 查询并发路径 `gather(return_exceptions=True)` 把 `CancelledError` 当业务失败转成 `QUERY_FAILED`——实测外部 `task.cancel()` 后返回正常响应且后续 mutation 照跑(master 回归)。修复 = `Exception` 走 per-field、其余 `BaseException` re-raise +- **P2 error path 合规**:别名存在时 `errors[].path` 用原始名而非 response key(GraphQL 规范要求 response key);两路径统一(`group_key` / `path_key`),message/日志保留原始名(便于人查 schema) +- **P3 恒真断言**:`assert ... or True` 换成精确形态断言(校验失败方法被 skip → 组序列化为 `{}` 非 null) +- polish:per-field 错误补 `extensions.service_method`;`validate_no_aliases` 补冒烟测试 + +### 新增公共 API(query_parser 模块级,非 breaking) +- `find_nested_alias(sel) -> (dotted_path, 原字段名) | None`——FR-009 检测的唯一实现(原先 query_executor / compose_executor / selection.py 三处等价重复,全部收敛复用) +- `nested_alias_message(path, name)`——统一报错措辞(`'reviews' aliased to 'r'`) +- `ResponseKeyConflictError(ValueError)`——duplicate response key 的类型化异常;`GraphQLHandler` 单独 catch 以输出 `ALIAS_CONFLICT` code(与 compose 对齐) + +### 行为增强(最终评估拍板,方案 A) +- **operation 级 mutation fail-stop**:原实现 fail-stop 只在单个 service/entity 组内生效,跨组不传播(实测 `SvcA` 失败后 `SvcB.write` 照常执行)——与 FR-006"失败之后的调用停止执行"字面不符、偏离 GraphQL operation 级串行语义。修复后 abort 标志跨组传播,后续组 mutation 一律 `SKIPPED_PRIOR_FAILURE`,query 不受影响。契约场景 4 已更新 +- 已知边界(未改,记录在案):`parse_document` 按 document 合并顶层键,同 document 多 operation 选同名组会触发 duplicate-key 报错(master 上为静默覆盖);单 operation-per-request 的现实下影响极小,如需支持须让 parser 按 operation 分组(牵动全部调用方,另立任务) + +### 验证 +- 全量 1660 passed / 6 skipped / 0 failed;中间 commit 快照已验证可 bisect diff --git a/src/nexusx/core_builder.py b/src/nexusx/core_builder.py index c61ef8b..b57fa54 100644 --- a/src/nexusx/core_builder.py +++ b/src/nexusx/core_builder.py @@ -149,6 +149,14 @@ def build_model( """ fields: dict[str, tuple[Any, Any]] = {} for field_name, child_sel in (selection.sub_fields or {}).items(): + # specs/023 FR-009: nested-field aliases (DTO/entity field renames) + # are out of scope — reject loudly instead of mis-projecting. + if getattr(child_sel, "alias", None) is not None: + raise SelectionError( + f"Field aliases are not supported at nested level " + f"('{child_sel.name}' aliased to '{field_name}'); only " + "method-level aliases are supported" + ) resolution = resolver.resolve_field(owner_type, field_name) field_path = f"{path}.{field_name}" if path else field_name diff --git a/src/nexusx/execution/query_executor.py b/src/nexusx/execution/query_executor.py index b4909db..2cad325 100644 --- a/src/nexusx/execution/query_executor.py +++ b/src/nexusx/execution/query_executor.py @@ -13,7 +13,7 @@ from nexusx.execution.argument_builder import ArgumentBuilder from nexusx.loader.pagination import KeyedPaginatedPackage, PaginatedPackage from nexusx.loader.registry import RelationshipKind -from nexusx.query_parser import FieldSelection +from nexusx.query_parser import FieldSelection, find_nested_alias, nested_alias_message from nexusx.response_builder import ( get_relation_entity, get_relationship_names, @@ -30,6 +30,16 @@ _CACHE_MISS = object() +def _node_key(node: FieldNode) -> str: + """Response key of a field node: alias when present, else field name. + + specs/023: mirrors ``QueryParser`` key semantics so executor lookups + into ``parsed_selections`` and ``sub_fields`` stay aligned when the + query carries aliases. + """ + return node.alias.value if node.alias else node.name.value + + class QueryExecutor: """Executes GraphQL queries using DataLoader for relationship resolution. @@ -87,6 +97,11 @@ async def execute_query( data: dict[str, Any] = {} errors: list[dict[str, Any]] = [] entity_names = {e.__name__ for e in entities} + # specs/023 FR-006 (operation-scope fail-stop): once a mutation + # fails, every later mutation in the same operation is skipped — + # across entity-group boundaries, matching GraphQL's operation-level + # serial mutation semantics. Queries are unaffected. + mutation_aborted = False # Clear caches for this request self._registry.clear_cache() @@ -104,6 +119,11 @@ async def execute_query( continue entity_name = selection.name.value + # specs/023 P2: data keys and error paths use RESPONSE keys + # (``alias or name``) per the GraphQL spec; ``entity_name`` + # stays the lookup identity and powers human-facing logs / + # messages. + group_key = _node_key(selection) # Introspection fields (query only) — not entity groups. if ( @@ -111,7 +131,7 @@ async def execute_query( and self._introspection_generator is not None and entity_name in {"__schema", "__type"} ): - data[entity_name] = self._introspection_generator.execute_field( + data[group_key] = self._introspection_generator.execute_field( selection, variables ) continue @@ -121,7 +141,9 @@ async def execute_query( # Selected an entity group without any method subselection. if method_group is not None and selection.selection_set is None: errors.append( - self._bare_group_field_error(entity_name, method_group) + self._bare_group_field_error( + entity_name, method_group, group_key + ) ) continue @@ -132,13 +154,15 @@ async def execute_query( f"Cannot query field '{entity_name}' on type " f"'{group_suffix}'" ), - "path": [entity_name], + "path": [group_key], } ) continue # Two-level: dispatch each method field inside the entity group. - data[entity_name] = await self._execute_entity_group( + # specs/023: mutation groups fail-stop (serial semantics); + # query groups keep executing siblings past a failure. + entity_data, aborted = await self._execute_entity_group( entity_name, method_group, selection, @@ -147,7 +171,11 @@ async def execute_query( entity_names, group_suffix, errors, + fail_stop=(op_type == "mutation"), + prior_aborted=mutation_aborted, ) + data[group_key] = entity_data + mutation_aborted = mutation_aborted or aborted response: dict[str, Any] = {} if data: @@ -166,24 +194,57 @@ async def _execute_entity_group( entity_names: set[str], group_suffix: str, errors: list[dict[str, Any]], - ) -> dict[str, Any] | None: + *, + fail_stop: bool = False, + prior_aborted: bool = False, + ) -> tuple[dict[str, Any], bool]: """Execute every method field selected inside one entity group. Methods run sequentially (v1) to preserve the BFS DataLoader's store-then-read deduplication invariants across sibling fields. - Returns ``None`` when any method raises: method return types are - non-null, so GraphQL null propagation nulls the group instead of - leaving a misleading empty-object ``Entity: {}`` partial result. + specs/023 (D4): failures are per-field — a failing method nulls its + own response key with an errors entry; sibling results are kept + (replaces the pre-023 whole-group nulling). With ``fail_stop`` + (mutation groups), every method after the first failure is skipped + and marked SKIPPED_PRIOR_FAILURE. + + Returns ``(entity_data, aborted)``: the second value tells the + caller a mutation failed here (or the abort was inherited) so later + groups in the same operation can skip their mutations too — FR-006 + operation-scope fail-stop. Only meaningful with ``fail_stop``; + query groups always report ``False``. """ entity_data: dict[str, Any] = {} - group_failed = False - group_sel = parsed_selections.get(entity_name) + # An inherited abort (a mutation failed in a PRIOR group of this + # operation) starts the group already in fail-stop. + group_failed = fail_stop and prior_aborted + # specs/023 P2: response key of the group (alias when present) — + # error paths use it so clients can locate errors inside the data + # they actually received; ``entity_name`` remains the lookup/log name. + group_key = _node_key(group_selection) + group_sel = parsed_selections.get(group_key) for method_node in group_selection.selection_set.selections: if not isinstance(method_node, FieldNode): continue method_name = method_node.name.value + response_key = _node_key(method_node) + + if group_failed: + # fail-stop (mutations): skipped keys stay null + flagged. + entity_data[response_key] = None + errors.append( + { + "message": ( + f"Skipped '{response_key}' because a prior " + "mutation failed" + ), + "path": [group_key, response_key], + "extensions": {"code": "SKIPPED_PRIOR_FAILURE"}, + } + ) + continue try: method_info = method_group.get(method_name) @@ -194,7 +255,7 @@ async def _execute_entity_group( f"Cannot query field '{method_name}' on type " f"'{entity_name}{group_suffix}'" ), - "path": [entity_name, method_name], + "path": [group_key, response_key], } ) continue @@ -204,7 +265,7 @@ async def _execute_entity_group( # The method's selection tree is nested one level deeper than # the entity-group selection. field_sel = ( - group_sel.sub_fields.get(method_name) + group_sel.sub_fields.get(response_key) if group_sel and group_sel.sub_fields else None ) @@ -226,7 +287,7 @@ async def _execute_entity_group( if not is_federation_batch_root and not self._validate_method_selection( entity, field_sel, - [entity_name, method_name], + [group_key, response_key], pag_root=pag_root, errors=errors, ): @@ -265,41 +326,46 @@ async def _execute_entity_group( ) # Serialize - entity_data[method_name] = self._serialize(result, entity, field_sel) + entity_data[response_key] = self._serialize( + result, entity, field_sel + ) except Exception as e: # Per-field exceptions are common (user input bugs, DB # constraint violations, resolver programming errors); the # response stays GraphQL-spec compliant ({message, path, # extensions}) while the server log retains the exception - # type and stack. The failed method nulls the whole group — - # sibling results are discarded, matching non-null GraphQL - # propagation at group granularity. + # type and stack. specs/023 (D4): the failed method nulls + # only its own response key — sibling results survive. + # fail_stop (mutation groups) skips the remainder. logger.exception( "Resolver error in field %s.%s", entity_name, method_name ) + entity_data[response_key] = None errors.append( { "message": str(e), - "path": [entity_name, method_name], + "path": [group_key, response_key], "extensions": {"code": "RESOLVER_ERROR"}, } ) - group_failed = True + group_failed = fail_stop - if group_failed: - return None - return entity_data + return entity_data, group_failed @staticmethod def _bare_group_field_error( - entity_name: str, method_group: dict[str, tuple[type[SQLModel], Any]] + entity_name: str, + method_group: dict[str, tuple[type[SQLModel], Any]], + group_key: str | None = None, ) -> dict[str, Any]: """Build the friendly error for selecting a bare entity group field. Fires when a query selects ``{ Entity }`` with no method subselection. ``{ Entity {} }`` (empty braces) is rejected by graphql-core's parser before reaching the executor, so only the truly-bare case is caught. + ``group_key`` (specs/023 P2) is the response key for the error path + when the group is aliased; defaults to the entity name. """ method_names = sorted(method_group) first = method_names[0] if method_names else "method_name" @@ -311,7 +377,7 @@ def _bare_group_field_error( f"{', '.join(method_names)}.\n" f"Example: {example}" ), - "path": [entity_name], + "path": [group_key or entity_name], "extensions": { "code": "BARE_GROUP_FIELD", "entity": entity_name, @@ -346,6 +412,17 @@ def _validate_method_selection( if field_sel is None or not field_sel.sub_fields: return True + # specs/023 FR-009: aliases below method level are out of scope — + # reject loudly (before execution) instead of silently mis-projecting. + nested = find_nested_alias(field_sel) + if nested is not None: + dotted, field_name = nested + errors.append({ + "message": nested_alias_message(dotted, field_name), + "path": [*path, dotted], + }) + return False + if pag_root is None: return self._validate_entity_fields(entity, field_sel, path, errors) diff --git a/src/nexusx/federation/remote_loader.py b/src/nexusx/federation/remote_loader.py index 61eeb8d..2c2eeb8 100644 --- a/src/nexusx/federation/remote_loader.py +++ b/src/nexusx/federation/remote_loader.py @@ -234,25 +234,48 @@ def _render_arguments(selection: Any) -> str: return f"({rendered})" +def _wire_render_name(fname: str, child: Any) -> str: + """Field name to put on the member-bound wire for one selection node. + + specs/023 US4: aliased nodes render their ORIGINAL field name + (``child.name``, captured by the parser) — dict keys are response keys + (``alias or name``) since 023 and aliases must not leak onto the wire. + Un-aliased nodes render the key: hand-built trees (e.g. + ``Resolver._build_nested_selection``) reuse ``name`` to carry the target + TYPE name, so only alias presence gates the substitution. + """ + if getattr(child, "alias", None) is not None and getattr(child, "name", None): + return child.name + return fname + + def _render_selection(sel: Any, indent: int = 6) -> str: """Render a FieldSelection subtree as a GraphQL selection set. Recurses into nested selections so the mounted service resolves its own subgraph (multi-hop). Dotted-name targets are never used here — only bare field names appear, matching the mounted service's (un-prefixed) schema. + + specs/023 US4: aliased nodes render from ``child.name`` (the original + field name the parser captured), never from the dict key — since 023 the + sub_fields keys are response keys (``alias or name``) and aliases must + not leak onto the member-bound wire. Nodes WITHOUT an alias always render + the key: hand-built trees (e.g. ``Resolver._build_nested_selection``) + reuse ``name`` to carry the target TYPE name, not the field name. """ pad = " " * indent lines: list[str] = [] sub_fields = getattr(sel, "sub_fields", None) or {} for fname, child in sub_fields.items(): + render_name = _wire_render_name(fname, child) child_sub = getattr(child, "sub_fields", None) or {} arg_str = _render_arguments(child) if child_sub: - lines.append(f"{pad}{fname}{arg_str} {{") + lines.append(f"{pad}{render_name}{arg_str} {{") lines.append(_render_selection(child, indent + 2)) lines.append(f"{pad}}}") else: - lines.append(f"{pad}{fname}{arg_str}") + lines.append(f"{pad}{render_name}{arg_str}") return "\n".join(lines) @@ -267,25 +290,31 @@ def build_gql_query( ) -> str: """Construct the nested GraphQL query document.""" # Field set: client selection (if any) plus the join key (needed for align). - wanted: list[str] = [] + # specs/023 US4: aliased nodes render their ORIGINAL field name + # (``child.name``, captured by the parser) — dict keys are response keys + # (``alias or name``) since 023 and aliases must not leak onto the wire. + # Un-aliased nodes render the key: hand-built trees (Resolver) reuse + # ``name`` for the target TYPE name, so only alias presence gates this. sub_fields = getattr(selection, "sub_fields", None) or {} - for fname in sub_fields: - wanted.append(fname) - if join_remote not in wanted: - wanted.append(join_remote) + entries: list[tuple[str, Any]] = [ + (_wire_render_name(fname, child), child) + for fname, child in sub_fields.items() + ] + rendered = [name for name, _ in entries] + if join_remote not in rendered: + entries.append((join_remote, None)) pad = " " body_lines: list[str] = [] - for fname in wanted: - child = sub_fields.get(fname) + for render_name, child in entries: child_sub = getattr(child, "sub_fields", None) if child else None arg_str = _render_arguments(child) if child is not None else "" if child_sub: - body_lines.append(f"{pad*2}{fname}{arg_str} {{") + body_lines.append(f"{pad*2}{render_name}{arg_str} {{") body_lines.append(_render_selection(child, 6)) body_lines.append(f"{pad*2}}}") else: - body_lines.append(f"{pad*2}{fname}{arg_str}") + body_lines.append(f"{pad*2}{render_name}{arg_str}") keys_lit = _render_keys(keys) return ( diff --git a/src/nexusx/handler.py b/src/nexusx/handler.py index 96e09d3..649f9c3 100644 --- a/src/nexusx/handler.py +++ b/src/nexusx/handler.py @@ -13,7 +13,7 @@ from nexusx.graphiql import GRAPHIQL_HTML from nexusx.introspection import IntrospectionGenerator from nexusx.loader.registry import ErManager -from nexusx.query_parser import QueryParser +from nexusx.query_parser import QueryParser, ResponseKeyConflictError from nexusx.sdl_generator import SDLGenerator from nexusx.standard_queries import AutoQueryConfig, add_standard_queries @@ -321,9 +321,10 @@ async def execute( Dictionary with 'data' and/or 'errors' keys. """ try: - self._query_parser.validate_no_aliases(query) - - # Parse once; share the AST between parser and executor + # Parse once; share the AST between parser and executor. + # (specs/023: aliases are supported at method level — the old + # blanket `validate_no_aliases` guard is retired from this call + # path; the parser now rejects duplicate response keys instead.) document = parse(query) parsed_selections = self._query_parser.parse_document( document, variables=variables @@ -340,6 +341,17 @@ async def execute( entities=self.entities, ) + except ResponseKeyConflictError as e: + # specs/023 FR-007: carry the machine-readable code on the + # entity-first path too (aligned with compose's ALIAS_CONFLICT). + return { + "errors": [ + { + "message": str(e), + "extensions": {"code": "ALIAS_CONFLICT"}, + } + ] + } except Exception as e: logger.exception("GraphQL execution error") return {"errors": [{"message": str(e)}]} diff --git a/src/nexusx/query_parser.py b/src/nexusx/query_parser.py index ef3b2d4..34292b6 100644 --- a/src/nexusx/query_parser.py +++ b/src/nexusx/query_parser.py @@ -8,6 +8,26 @@ from graphql import DocumentNode, FieldNode, OperationDefinitionNode, parse +def _conflict_message(key: str, where: str) -> str: + """Error text for a duplicate response key (specs/023 FR-007).""" + return ( + f"Response key conflict: '{key}' is selected more than once at " + f"'{where}'. Aliases, alias/field-name collisions, and plain " + "duplicate fields all conflict — field merging is not supported." + ) + + +class ResponseKeyConflictError(ValueError): + """Duplicate response key at one selection level (specs/023 FR-007). + + Raised by ``QueryParser`` for alias repeats, alias/field-name + collisions, and plain duplicate fields — field merging is not + supported. Subclasses ``ValueError`` so pre-023 ``except ValueError`` + callers keep working; handlers that want a machine-readable code catch + this type specifically (compose maps it to ``ALIAS_CONFLICT``). + """ + + @dataclass class FieldSelection: """Represents a selected field with its nested selections and arguments. @@ -25,6 +45,46 @@ class FieldSelection: sub_fields: dict[str, FieldSelection] = field(default_factory=dict) +def find_nested_alias(sel: FieldSelection) -> tuple[str, str] | None: + """First alias strictly BELOW ``sel``, as ``(dotted_path, field_name)``. + + specs/023 FR-009 shared helper: nested-field aliases are out of scope on + every execution path (entity-first serialization is lenient — unknown + fields fall back to ``Any`` — so a nested alias would silently + mis-project). All paths detect-and-reject through this single walk. + + ``dotted_path`` is the response-key path from ``sel`` down to the aliased + node (e.g. ``"owner.reviews"``); ``field_name`` is the ORIGINAL field + name of the aliased node (``child.name``), so error messages can render + ``'reviews' aliased to 'r'`` instead of the bare alias key. + """ + def _walk(selection: FieldSelection) -> tuple[str, str] | None: + for key, child in (selection.sub_fields or {}).items(): + if child.alias is not None: + return key, child.name + deeper = _walk(child) + if deeper is not None: + return f"{key}.{deeper[0]}", deeper[1] + return None + + return _walk(sel) + + +def nested_alias_message(dotted: str, field_name: str) -> str: + """Error text for a nested-field alias (specs/023 FR-009). + + Shared by every reject site so the wording stays identical: + ``Field aliases are not supported at nested level ('reviews' aliased + to 'r'); only method-level aliases are supported``. + """ + alias_key = dotted.rsplit(".", 1)[-1] + return ( + "Field aliases are not supported at nested level " + f"('{field_name}' aliased to '{alias_key}'); " + "only method-level aliases are supported" + ) + + class QueryParser: """Parses GraphQL queries to extract field selections and arguments.""" @@ -64,6 +124,10 @@ def parse_document( ``variables`` resolves query variables in arguments (specs/021 hardening: without it, a variable argument becomes ``Undefined`` and pagination params crash downstream). + + specs/023: dict keys are RESPONSE keys (``alias or field_name``); + ``FieldSelection.name`` keeps the original field name for lookups. + Duplicate response keys at any level raise ``ValueError``. """ result: dict[str, FieldSelection] = {} @@ -72,16 +136,32 @@ def parse_document( for selection in definition.selection_set.selections: if isinstance(selection, FieldNode): operation_name = selection.name.value + alias = ( + selection.alias.value if selection.alias else None + ) + key = alias or operation_name + if key in result: + raise ResponseKeyConflictError( + _conflict_message(key, "top level") + ) if selection.selection_set: meta = self._parse_selection_set( - selection.selection_set, variables + selection.selection_set, variables, path=key ) - result[operation_name] = meta + meta.name = operation_name + meta.alias = alias + result[key] = meta return result def validate_no_aliases(self, query: str) -> None: - """Reject GraphQL aliases explicitly.""" + """Reject GraphQL aliases explicitly. + + Optional user-side guard. Since specs/023 the built-in executors + SUPPORT method-level aliases (response keys become the aliases), so + nexusx no longer calls this internally — keep it for custom handlers + that want to forbid aliases on their own surfaces. + """ document = parse(query) for definition in document.definitions: @@ -100,27 +180,43 @@ def _validate_selection_set_no_aliases(self, selection_set: Any) -> None: self._validate_selection_set_no_aliases(nested_selection_set) def _parse_selection_set( - self, selection_set: Any, variables: dict[str, Any] | None = None + self, + selection_set: Any, + variables: dict[str, Any] | None = None, + path: str = "", ) -> FieldSelection: - """Internal method to parse selection set into FieldSelection.""" + """Internal method to parse selection set into FieldSelection. + + specs/023: ``sub_fields`` is keyed by RESPONSE key (``alias or + field_name``) so same-name fields with different aliases no longer + overwrite each other (Issue #140). Duplicate response keys — alias + repeats, alias/field-name collisions, and plain duplicate fields — + raise ``ValueError`` (field merging is intentionally not supported). + """ sub_fields: dict[str, FieldSelection] = {} for selection in selection_set.selections: if isinstance(selection, FieldNode): field_name = selection.name.value alias = selection.alias.value if selection.alias else None + key = alias or field_name + if key in sub_fields: + raise ResponseKeyConflictError( + _conflict_message(key, path or "root") + ) arguments = self._extract_arguments(selection, variables) if selection.selection_set: nested = self._parse_selection_set( - selection.selection_set, variables + selection.selection_set, variables, + path=f"{path}.{key}" if path else key, ) nested.name = field_name nested.alias = alias nested.arguments = arguments - sub_fields[field_name] = nested + sub_fields[key] = nested else: - sub_fields[field_name] = FieldSelection( + sub_fields[key] = FieldSelection( name=field_name, alias=alias, arguments=arguments, diff --git a/src/nexusx/use_case/compose_executor.py b/src/nexusx/use_case/compose_executor.py index 517dbfc..32404f7 100644 --- a/src/nexusx/use_case/compose_executor.py +++ b/src/nexusx/use_case/compose_executor.py @@ -29,7 +29,7 @@ from graphql import DocumentNode, FieldNode, OperationDefinitionNode, parse from pydantic import BaseModel, TypeAdapter -from nexusx.query_parser import FieldSelection, QueryParser +from nexusx.query_parser import FieldSelection, QueryParser, find_nested_alias from nexusx.use_case.business import UseCaseService from nexusx.use_case.compose_schema import ComposeSchema from nexusx.use_case.compose_type_mapper import is_from_context_annotation @@ -103,20 +103,40 @@ async def execute_compose_query( return _error_response(_INTROSPECTION_REJECTION_HINT) # 3. Convert AST → FieldSelection tree (reuses existing QueryParser). + # specs/023: keys are response keys; duplicate-key conflicts from the + # parser surface as ALIAS_CONFLICT errors (FR-007). parser = QueryParser() - selections = parser.parse_document(document) + try: + selections = parser.parse_document(document) + except ValueError as exc: + return _error_response(str(exc), code="ALIAS_CONFLICT") if not selections: return _error_response("Query has no operations.") + # 3.5 specs/023 US2: method-level aliases are supported on @query + # methods; nested-field aliases are out of scope and rejected + # loudly (FR-009) — never silently dropped. + nested_alias = _find_nested_alias(selections) + if nested_alias is not None: + dotted, field_name = nested_alias + return _error_response( + "Nested-field aliases are not supported by compose_query " + f"('{field_name}' aliased to '{dotted.rsplit('.', 1)[-1]}' " + f"at '{dotted}'); only method-level aliases are.", + code="ALIAS_CONFLICT", + ) + # 4. For each operation, plan + execute. try: - data = await _execute_operations(app, schema, selections, context or {}) + data, field_errors = await _execute_operations( + app, schema, selections, context or {} + ) except _ComposeExecutionError as exc: - return _error_response(str(exc), exc.service_method) + return _error_response(str(exc), exc.service_method, code=exc.code) except Exception as exc: # noqa: BLE001 — surface as graphql error, not 500 return _error_response(f"{type(exc).__name__}: {exc}") - return {"data": data, "errors": []} + return {"data": data, "errors": field_errors} # --------------------------------------------------------------------------- @@ -127,9 +147,15 @@ async def execute_compose_query( class _ComposeExecutionError(Exception): """Internal control-flow exception carrying service/method context.""" - def __init__(self, message: str, service_method: str | None = None) -> None: + def __init__( + self, + message: str, + service_method: str | None = None, + code: str | None = None, + ) -> None: super().__init__(message) self.service_method = service_method + self.code = code async def _execute_operations( @@ -137,20 +163,36 @@ async def _execute_operations( schema: ComposeSchema, selections: dict[str, FieldSelection], context: dict[str, Any], -) -> dict[str, Any]: - """Run every operation in ``selections`` and return the nested data dict. +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Run every operation in ``selections``; return ``(data, field_errors)``. For v1 simplicity: operations are executed sequentially. Within an operation, ``@query`` methods run concurrently via ``asyncio.gather`` and ``@mutation`` methods run serially in query-declaration order. This mirrors pydantic-resolve's compose executor. + + specs/023: execution-time failures are collected per field (the failing + response key becomes ``null`` with an ``errors`` entry) instead of + nulling the whole response; planning errors (unknown service/method, + capability gates) still fail fast. + + specs/023 FR-006 (operation-scope fail-stop): once a mutation fails, + every LATER mutation in the same operation is skipped and marked + SKIPPED_PRIOR_FAILURE — across service-group boundaries, matching + GraphQL's operation-level serial mutation semantics. Queries are + unaffected by the abort flag. """ services_by_name: dict[str, type[UseCaseService]] = { cls.__name__: cls for cls in app.services } data: dict[str, Any] = {} - for service_name, service_sel in selections.items(): + errors: list[dict[str, Any]] = [] + mutation_aborted = False + for service_key, service_sel in selections.items(): + # specs/023: lookup identity is the original name, the response key + # (which may be an alias) is what the caller sees. + service_name = service_sel.name or service_key service_cls = services_by_name.get(service_name) if service_cls is None: raise _ComposeExecutionError( @@ -158,15 +200,19 @@ async def _execute_operations( f"Available: {sorted(services_by_name)}.", service_method=service_name, ) - method_results = await _execute_service_methods( + method_results, method_errors, aborted = await _execute_service_methods( app=app, schema=schema, service_cls=service_cls, service_sel=service_sel, context=context, + service_key=service_key, + mutation_aborted=mutation_aborted, ) - data[service_name] = method_results - return data + mutation_aborted = mutation_aborted or aborted + data[service_key] = method_results + errors.extend(method_errors) + return data, errors async def _execute_service_methods( @@ -175,14 +221,30 @@ async def _execute_service_methods( service_cls: type[UseCaseService], service_sel: FieldSelection, context: dict[str, Any], -) -> dict[str, Any]: - """Run each method selection under one service and return {method: result}.""" + service_key: str | None = None, + mutation_aborted: bool = False, +) -> tuple[dict[str, Any], list[dict[str, Any]], bool]: + """Run each method selection under one service. + + Returns ``(results, errors, mutation_aborted)``: the third value tells + the caller whether a mutation failed here (or the abort was inherited + from a prior group) so later groups can skip their mutations too + (specs/023 FR-006 operation-scope fail-stop). + + ``service_key`` (specs/023 P2) is the response key of the service group + (alias when present) — error paths use it so clients can locate errors + inside the data they received; defaults to the class name. + """ + path_key = service_key or service_cls.__name__ methods = getattr(service_cls, "__use_case_methods__", {}) # Bucket methods by kind so we can run queries concurrently and mutations serially. query_tasks: list[tuple[str, Awaitable[Any]]] = [] mutation_specs: list[tuple[str, FieldSelection]] = [] - for method_name, method_sel in service_sel.sub_fields.items(): + for method_key, method_sel in service_sel.sub_fields.items(): + # specs/023: resolve the method by its original name; the response + # key (dict key, alias when present) is preserved for the caller. + method_name = method_sel.name or method_key meta = methods.get(method_name) if meta is None: raise _ComposeExecutionError( @@ -197,10 +259,10 @@ async def _execute_service_methods( service_method=f"{service_cls.__name__}.{method_name}", ) if kind == "mutation": - mutation_specs.append((method_name, method_sel)) + mutation_specs.append((method_key, method_sel)) else: query_tasks.append( - (method_name, _invoke_and_project( + (method_key, _invoke_and_project( app=app, schema=schema, service_cls=service_cls, @@ -211,27 +273,103 @@ async def _execute_service_methods( ) results: dict[str, Any] = {} + errors: list[dict[str, Any]] = [] - # Queries: concurrent. + # Queries: concurrent. (specs/023 US2: aliased queries fan out — each + # alias invocation is an independent call; no method-level dedup. A + # failing invocation nulls only its own response key.) if query_tasks: - names = [n for n, _ in query_tasks] awaitables = [a for _, a in query_tasks] - values = await asyncio.gather(*awaitables) - for name, value in zip(names, values, strict=True): - results[name] = value - - # Mutations: serial in declaration order. - for method_name, method_sel in mutation_specs: - results[method_name] = await _invoke_and_project( - app=app, - schema=schema, - service_cls=service_cls, - method_name=method_name, - method_sel=method_sel, - context=context, - ) - - return results + values = await asyncio.gather(*awaitables, return_exceptions=True) + for (key, _sel), value in zip(query_tasks, values, strict=True): + if isinstance(value, Exception): + results[key] = None + message = ( + str(value) + if isinstance(value, _ComposeExecutionError) + else f"{type(value).__name__}: {value}" + ) + extensions: dict[str, Any] = {"code": "QUERY_FAILED"} + # specs/023 polish: carry the qualified method on per-field + # errors too (the fail-fast path always has) — MCP consumers + # locate the failing method without parsing the message. + if ( + isinstance(value, _ComposeExecutionError) + and value.service_method + ): + extensions["service_method"] = value.service_method + errors.append({ + "message": message, + "path": [path_key, key], + "extensions": extensions, + }) + elif isinstance(value, BaseException): + # Cancellation is not a field failure. gather(return_ + # exceptions=True) delivers CancelledError (and + # KeyboardInterrupt / SystemExit) as VALUES — re-raise so a + # cancelled request actually stops instead of running its + # mutations and returning a normal response. + raise value + else: + results[key] = value + + # Mutations: serial in declaration order with per-call three-state + # feedback (specs/023 US3, FR-005/FR-006): a succeeded call keeps its + # result, a failed call nulls only its own key with MUTATION_FAILED, + # and fail-stop skips every later call as SKIPPED_PRIOR_FAILURE — + # already-executed writes are never erased from the response (D4). + # The flag may arrive pre-set from a prior service group (FR-006 + # operation-scope fail-stop); queries above already ran unaffected. + mutation_failed = mutation_aborted + for method_key, method_sel in mutation_specs: + if mutation_failed: + results[method_key] = None + errors.append({ + "message": ( + f"Skipped '{method_key}' because a prior mutation failed" + ), + "path": [path_key, method_key], + "extensions": {"code": "SKIPPED_PRIOR_FAILURE"}, + }) + continue + try: + results[method_key] = await _invoke_and_project( + app=app, + schema=schema, + service_cls=service_cls, + method_name=method_sel.name, + method_sel=method_sel, + context=context, + ) + except _ComposeExecutionError as exc: + results[method_key] = None + errors.append({ + "message": str(exc), + "path": [path_key, method_key], + "extensions": { + "code": "MUTATION_FAILED", + "service_method": ( + exc.service_method + or f"{service_cls.__name__}.{method_sel.name}" + ), + }, + }) + mutation_failed = True + except Exception as exc: # noqa: BLE001 — defensive: keep three-state shape + results[method_key] = None + errors.append({ + "message": f"{type(exc).__name__}: {exc}", + "path": [path_key, method_key], + "extensions": { + "code": "MUTATION_FAILED", + "service_method": ( + f"{service_cls.__name__}.{method_sel.name}" + ), + }, + }) + mutation_failed = True + + return results, errors, mutation_failed async def _invoke_and_project( @@ -446,10 +584,41 @@ def _selection_set_uses_introspection(selection_set: Any) -> bool: return False -def _error_response(message: str, service_method: str | None = None) -> dict[str, Any]: +def _find_nested_alias( + selections: dict[str, FieldSelection], +) -> tuple[str, str] | None: + """Find an alias BELOW method level (specs/023 FR-009: unsupported). + + Method-level aliases (``a: method(args)``) are supported on @query + methods; any alias one level deeper — DTO field renames inside a + method's selection — is out of scope and must be rejected loudly + instead of silently mis-projecting. Delegates the per-method walk to + ``query_parser.find_nested_alias`` (the shared FR-009 helper) and + prefixes the service/method response keys. Returns + ``(dotted_path, field_name)`` or None. + """ + for service_key, service_sel in selections.items(): + for method_key, method_sel in service_sel.sub_fields.items(): + found = find_nested_alias(method_sel) + if found is not None: + dotted, field_name = found + return f"{service_key}.{method_key}.{dotted}", field_name + return None + + +def _error_response( + message: str, + service_method: str | None = None, + code: str | None = None, +) -> dict[str, Any]: error: dict[str, Any] = {"message": message} + extensions: dict[str, Any] = {} if service_method is not None: - error["extensions"] = {"service_method": service_method} + extensions["service_method"] = service_method + if code is not None: + extensions["code"] = code + if extensions: + error["extensions"] = extensions return {"data": None, "errors": [error]} diff --git a/src/nexusx/use_case/selection.py b/src/nexusx/use_case/selection.py index e2fc6c2..e363696 100644 --- a/src/nexusx/use_case/selection.py +++ b/src/nexusx/use_case/selection.py @@ -18,7 +18,7 @@ from pydantic_core import PydanticUndefined from nexusx.core_builder import FieldResolution, SelectionError, build_model -from nexusx.query_parser import FieldSelection, QueryParser +from nexusx.query_parser import FieldSelection, QueryParser, find_nested_alias from nexusx.utils.type_utils import map_annotation _UNION_ORIGINS = (typing.Union, _UnionType) @@ -73,9 +73,25 @@ def parse_selection(selection: str) -> FieldSelection: raise SelectionError("selection must include at least one field") _reject_arguments(root) + _reject_aliases(root) return root +def _reject_aliases(selection: FieldSelection) -> None: + """specs/023 FR-009: field aliases in a ``--select`` projection are + unsupported — reject with a clear message instead of treating the alias + as an unknown field name. Shares the tree walk with the GraphQL paths + (``query_parser.find_nested_alias``).""" + found = find_nested_alias(selection) + if found is not None: + dotted, field_name = found + raise SelectionError( + f"field aliases are not supported in selection " + f"('{field_name}' aliased to '{dotted.rsplit('.', 1)[-1]}' " + f"at '{dotted}')" + ) + + class DTOFieldResolver: """FieldResolver for UseCase compose (specs/021 path-merge). diff --git a/tests/test_compose_executor.py b/tests/test_compose_executor.py index edb6dbb..a176166 100644 --- a/tests/test_compose_executor.py +++ b/tests/test_compose_executor.py @@ -10,7 +10,8 @@ from __future__ import annotations -from typing import Annotated +import asyncio +from typing import Annotated, ClassVar import pytest from pydantic import BaseModel @@ -255,7 +256,8 @@ async def test_missing_required_from_context_errors_cleanly( "{ ContextService { echo_actor } }", context={}, # no "actor" key ) - assert result["data"] is None + # specs/023: per-field — the failing key nulls, siblings unaffected. + assert result["data"] == {"ContextService": {"echo_actor": None}} assert len(result["errors"]) == 1 assert "Required FromContext parameter 'actor'" in result["errors"][0]["message"] @@ -327,6 +329,8 @@ async def test_unknown_method(self, app, schema) -> None: async def test_method_exception_becomes_error(self, app, schema) -> None: # get_user returns None for user_id != 1, no exception. Use a service # that explicitly raises to verify the exception path. + # specs/023: execution failures are per-field — the key nulls with an + # errors entry (QUERY_FAILED) instead of nulling the whole response. class RaisingService(UseCaseService): @query async def boom(cls) -> int: @@ -337,8 +341,16 @@ async def boom(cls) -> int: result = await execute_compose_query( app2, schema2, "{ RaisingService { boom } }" ) - assert result["data"] is None + assert result["data"] == {"RaisingService": {"boom": None}} assert "RaisingService.boom raised RuntimeError" in result["errors"][0]["message"] + assert result["errors"][0]["extensions"]["code"] == "QUERY_FAILED" + # specs/023 polish: per-field errors carry the qualified method too + # (aligned with the fail-fast path) — MCP consumers locate the + # failure without parsing the message. + assert ( + result["errors"][0]["extensions"]["service_method"] + == "RaisingService.boom" + ) async def test_malformed_query_returns_parse_error(self, app, schema) -> None: result = await execute_compose_query( @@ -415,3 +427,469 @@ async def test_resolve_field_left_untouched_when_service_skips_resolver( # Sanity check that the DTO + Resolver would otherwise do the right thing. processed = await Resolver().resolve(_ResolverAwareDTO(id=5)) assert processed.derived == 500 + + +# ────────────────────────────────────────────────────────────────────── +# US1 (specs/023): aliases must fail loudly — never silently dropped +# ────────────────────────────────────────────────────────────────────── + + +class _AliasGuardDTO(BaseModel): + id: int + label: str + + +class _AliasGuardService(UseCaseService): + """Counts invocations so tests can assert zero execution on rejection.""" + + calls: ClassVar[list[str]] = [] + + @query + async def fetch(cls, tag: str = "x") -> list[_AliasGuardDTO]: + _AliasGuardService.calls.append(f"fetch:{tag}") + return [_AliasGuardDTO(id=1, label=tag)] + + @query + async def boom(cls, tag: str = "x") -> list[_AliasGuardDTO]: + _AliasGuardService.calls.append(f"boom:{tag}") + raise RuntimeError(f"boom {tag}") + + @mutation + async def write(cls, tag: str = "x") -> _AliasGuardDTO: + _AliasGuardService.calls.append(f"write:{tag}") + return _AliasGuardDTO(id=2, label=tag) + + @mutation + async def flaky_write(cls, tag: str = "x") -> _AliasGuardDTO: + _AliasGuardService.calls.append(f"flaky_write:{tag}") + if tag == "fail": + raise RuntimeError("flaky exploded") + return _AliasGuardDTO(id=3, label=tag) + + +def _guard_app() -> UseCaseAppConfig: + _AliasGuardService.calls = [] + return UseCaseAppConfig(name="guard", services=[_AliasGuardService]) + + +class TestAliasRejection: + """US1: any alias in a compose query → explicit error, zero execution.""" + + async def test_aliased_query_field_is_supported(self) -> None: + """specs/023 B1a: aliased @query methods are now supported (US2). + + Superseded the US1-phase rejection; see TestAliasQueryFanout for the + full matrix (different args, dedup-free, projection isolation).""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "{ _AliasGuardService { a: fetch(tag: \"q\") { id } } }" + ) + assert result["errors"] == [] + assert result["data"]["_AliasGuardService"]["a"][0].id == 1 + assert _AliasGuardService.calls == ["fetch:q"] + + async def test_aliased_mutation_field_is_supported(self) -> None: + """specs/023 B1b: aliased mutations execute serially (US3). + + Superseded the US1/US2-phase rejection; TestAliasMutationThreeState + covers the full three-state matrix (fail-stop, dedup-free).""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + 'mutation { _AliasGuardService { a: write(tag: "m") { id } } }', + ) + assert result["errors"] == [] + assert result["data"]["_AliasGuardService"]["a"].id == 2 + assert _AliasGuardService.calls == ["write:m"] + + async def test_nested_alias_is_rejected(self) -> None: + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "{ _AliasGuardService { fetch { l: label } } }" + ) + assert result["data"] is None + assert result["errors"], "nested alias must produce an error" + assert result["errors"][0]["extensions"]["code"] == "ALIAS_CONFLICT" + assert _AliasGuardService.calls == [] + + async def test_unaliased_query_still_works(self) -> None: + """Guard against over-rejection: no alias → normal execution.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, '{ _AliasGuardService { fetch(tag: "ok") { id label } } }' + ) + assert result["errors"] == [] + assert result["data"]["_AliasGuardService"]["fetch"][0].label == "ok" + assert _AliasGuardService.calls == ["fetch:ok"] + + +class TestAliasQueryFanout: + """US2 (specs/023): aliased query methods execute independently.""" + + async def test_two_aliases_different_args_both_execute(self) -> None: + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + '{ _AliasGuardService { ' + 'a: fetch(tag: "hi") { id label } ' + 'b: fetch(tag: "lo") { id label } } }', + ) + assert result["errors"] == [] + data = result["data"]["_AliasGuardService"] + assert set(data) == {"a", "b"} + assert data["a"][0].label == "hi" + assert data["b"][0].label == "lo" + assert sorted(_AliasGuardService.calls) == ["fetch:hi", "fetch:lo"] + + async def test_same_method_same_args_not_deduplicated(self) -> None: + """FR-011 (query half): identical aliased calls still run N times.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + '{ _AliasGuardService { ' + 'a: fetch(tag: "same") { id } ' + 'b: fetch(tag: "same") { id } } }', + ) + assert result["errors"] == [] + assert _AliasGuardService.calls == ["fetch:same", "fetch:same"] + + async def test_projection_isolated_per_alias(self) -> None: + """Each alias projects only the sub-fields it declared.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + '{ _AliasGuardService { ' + 'a: fetch(tag: "p") { id } ' + 'b: fetch(tag: "p") { id label } } }', + ) + assert result["errors"] == [] + data = result["data"]["_AliasGuardService"] + assert set(data["a"][0].model_dump()) == {"id"} + assert set(data["b"][0].model_dump()) == {"id", "label"} + + async def test_failed_alias_does_not_kill_sibling(self) -> None: + """Queries have no fail-stop: a failing alias nulls only itself.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + '{ _AliasGuardService { ' + 'good: fetch(tag: "ok") { id } ' + 'bad: boom(tag: "nope") { id } } }', + ) + assert result["data"]["_AliasGuardService"]["good"][0].id == 1 + assert result["data"]["_AliasGuardService"]["bad"] is None + assert any( + "boom nope" in e["message"] for e in result["errors"] + ) + + async def test_response_key_conflict_reports_error(self) -> None: + """FR-007: duplicate response keys are rejected, not deduplicated.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "{ _AliasGuardService { a: fetch { id } a: fetch { id } } }" + ) + assert result["data"] is None + assert "conflict" in result["errors"][0]["message"].lower() + assert _AliasGuardService.calls == [] + + +class TestAliasMutationThreeState: + """US3 (specs/023): aliased mutations run serially with per-call feedback. + + Three states per response key: succeeded (value), failed (null + + MUTATION_FAILED), skipped (null + SKIPPED_PRIOR_FAILURE, fail-stop). + """ + + async def test_issue140_end_to_end_all_invocations_execute(self) -> None: + """The original report: 6 aliased add_node created 1 node.""" + app = _guard_app() + schema = build_compose_schema(app) + q = ( + "mutation { _AliasGuardService { " + + " ".join(f'n{i}: write(tag: "n{i}") {{ id label }}' for i in range(1, 7)) + + " } }" + ) + result = await execute_compose_query(app, schema, q) + assert result["errors"] == [] + data = result["data"]["_AliasGuardService"] + assert set(data) == {f"n{i}" for i in range(1, 7)} + assert all(data[k].label == k for k in data) + assert len(_AliasGuardService.calls) == 6 + + async def test_partial_failure_three_states(self) -> None: + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + "mutation { _AliasGuardService { " + 'first: flaky_write(tag: "ok") { id label } ' + 'bad: flaky_write(tag: "fail") { id } ' + 'last: flaky_write(tag: "also-ok") { id } } }', + ) + data = result["data"]["_AliasGuardService"] + # Succeeded results survive (D4: no whole-group nulling). + assert data["first"].id == 3 + assert data["first"].label == "ok" + # The failing call nulls its own key with MUTATION_FAILED. + assert data["bad"] is None + bad = [e for e in result["errors"] + if e.get("extensions", {}).get("code") == "MUTATION_FAILED"] + assert len(bad) == 1 + assert bad[0]["path"] == ["_AliasGuardService", "bad"] + # Fail-stop: everything after the failure is SKIPPED, not executed. + assert data["last"] is None + skipped = [e for e in result["errors"] + if e.get("extensions", {}).get("code") == "SKIPPED_PRIOR_FAILURE"] + assert len(skipped) == 1 + assert skipped[0]["path"] == ["_AliasGuardService", "last"] + assert _AliasGuardService.calls == ["flaky_write:ok", "flaky_write:fail"] + + async def test_serial_declaration_order(self) -> None: + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + "mutation { _AliasGuardService { " + 'c: write(tag: "3") { id } ' + 'a: write(tag: "1") { id } ' + 'b: write(tag: "2") { id } } }', + ) + assert result["errors"] == [] + # Execution follows DECLARATION order (c, a, b), not alias sort. + assert _AliasGuardService.calls == ["write:3", "write:1", "write:2"] + assert list(result["data"]["_AliasGuardService"]) == ["c", "a", "b"] + + async def test_same_method_same_args_n_side_effects(self) -> None: + """FR-011 mutation half: identical aliased mutations must ALL run.""" + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + 'mutation { _AliasGuardService { ' + 'a: write(tag: "dup") { id } b: write(tag: "dup") { id } } }', + ) + assert result["errors"] == [] + assert _AliasGuardService.calls == ["write:dup", "write:dup"] + + async def test_enable_mutation_false_orthogonal_to_aliases(self) -> None: + app = UseCaseAppConfig( + name="guard", services=[_AliasGuardService], enable_mutation=False + ) + _AliasGuardService.calls = [] + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, 'mutation { _AliasGuardService { a: write { id } } }' + ) + assert result["data"] is None + assert "enable_mutation=False" in result["errors"][0]["message"] + assert _AliasGuardService.calls == [] + + +class TestTopLevelAlias: + """specs/023: service-level aliases — lookup by original name, response + keyed by the alias.""" + + async def test_two_aliased_service_groups_fan_out(self) -> None: + app = _guard_app() + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, + '{ g1: _AliasGuardService { fetch(tag: "a") { id } } ' + 'g2: _AliasGuardService { fetch(tag: "b") { id } } }', + ) + assert result["errors"] == [] + data = result["data"] + assert set(data) == {"g1", "g2"} + assert data["g1"]["fetch"][0].id == 1 + assert sorted(_AliasGuardService.calls) == ["fetch:a", "fetch:b"] + + async def test_aliased_group_error_path_uses_response_keys(self) -> None: + """specs/023 P2: error paths carry response keys — a failing method + under an aliased group reports path ['group_alias', 'method_alias'], + matching the data keys the client received.""" + + class BoomService(UseCaseService): + @query + async def boom(cls) -> int: + raise RuntimeError("kaboom") + + app = UseCaseAppConfig(name="boomapp", services=[BoomService]) + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "{ g: BoomService { b: boom } }" + ) + assert result["data"] == {"g": {"b": None}} + assert result["errors"][0]["path"] == ["g", "b"] + assert result["errors"][0]["extensions"]["code"] == "QUERY_FAILED" + + +class TestQueryCancellationPropagates: + """specs/023 review P1: gather(return_exceptions=True) delivers + CancelledError as a VALUE — it must be re-raised, not downgraded to a + QUERY_FAILED error entry. Otherwise a cancelled request (client + disconnect, asyncio.timeout) keeps executing its mutations and returns + a normal response.""" + + async def test_cancelled_query_method_propagates_and_skips_mutations( + self, + ) -> None: + calls: list[str] = [] + + class CancelService(UseCaseService): + @query + async def cancel_me(cls) -> int: + calls.append("cancel_me") + raise asyncio.CancelledError() + + @mutation + async def write(cls) -> int: + calls.append("write") + return 1 + + app = UseCaseAppConfig( + name="cancel", services=[CancelService], enable_mutation=True + ) + schema = build_compose_schema(app) + with pytest.raises(asyncio.CancelledError): + await execute_compose_query( + app, schema, "{ CancelService { cancel_me write { id } } }" + ) + assert calls == ["cancel_me"] # the mutation never ran + + async def test_external_cancel_stops_execution(self) -> None: + started = asyncio.Event() + calls: list[str] = [] + + class SlowService(UseCaseService): + @query + async def slow(cls) -> int: + calls.append("slow") + started.set() + await asyncio.sleep(10) + return 1 + + @mutation + async def write(cls) -> int: + calls.append("write") + return 2 + + app = UseCaseAppConfig( + name="slow", services=[SlowService], enable_mutation=True + ) + schema = build_compose_schema(app) + + async def run() -> None: + task = asyncio.create_task( + execute_compose_query( + app, schema, "{ SlowService { slow write { id } } }" + ) + ) + await started.wait() + task.cancel() + await task + + with pytest.raises(asyncio.CancelledError): + await run() + assert calls == ["slow"] # cancelled before the mutation ran + + async def test_plain_exception_still_per_field(self) -> None: + """Regression guard: ordinary Exception stays a per-field + QUERY_FAILED (sibling results kept) — the fix only changes the + non-Exception BaseException path.""" + + class BoomService(UseCaseService): + @query + async def boom(cls) -> int: + raise RuntimeError("kaboom") + + @query + async def ok(cls) -> int: + return 7 + + app = UseCaseAppConfig(name="boomsvc", services=[BoomService]) + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "{ BoomService { boom ok } }" + ) + assert result["data"] == {"BoomService": {"boom": None, "ok": 7}} + assert result["errors"][0]["extensions"]["code"] == "QUERY_FAILED" + + +class TestOperationScopeFailStop: + """specs/023 FR-006 (review follow-up): mutation fail-stop propagates + across service-group boundaries — GraphQL mutations are serial at + OPERATION level. Later groups' mutations are skipped and marked + SKIPPED_PRIOR_FAILURE; queries are unaffected by the abort flag.""" + + async def test_later_service_mutations_skipped_after_earlier_failure( + self, + ) -> None: + calls: list[str] = [] + + class SvcA(UseCaseService): + @mutation + async def bad(cls) -> int: + calls.append("bad") + raise RuntimeError("A exploded") + + class SvcB(UseCaseService): + @mutation + async def write(cls) -> int: + calls.append("write") + return 99 + + app = UseCaseAppConfig( + name="opstop", services=[SvcA, SvcB], enable_mutation=True + ) + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "mutation { SvcA { bad } SvcB { write } }" + ) + assert result["data"] == { + "SvcA": {"bad": None}, + "SvcB": {"write": None}, + } + assert [ + (e["extensions"]["code"], e["path"]) for e in result["errors"] + ] == [ + ("MUTATION_FAILED", ["SvcA", "bad"]), + ("SKIPPED_PRIOR_FAILURE", ["SvcB", "write"]), + ] + assert calls == ["bad"] # SvcB.write never executed + + async def test_queries_unaffected_by_abort_flag(self) -> None: + """The abort flag only gates mutations — a later group's queries + still run (FR-005: query failures have no fail-stop either way).""" + calls: list[str] = [] + + class SvcA(UseCaseService): + @mutation + async def bad(cls) -> int: + calls.append("bad") + raise RuntimeError("A exploded") + + class SvcB(UseCaseService): + @query + async def fetch(cls) -> int: + calls.append("fetch") + return 7 + + app = UseCaseAppConfig( + name="opstopq", services=[SvcA, SvcB], enable_mutation=True + ) + schema = build_compose_schema(app) + result = await execute_compose_query( + app, schema, "mutation { SvcA { bad } SvcB { fetch } }" + ) + assert result["data"]["SvcB"]["fetch"] == 7 + assert calls == ["bad", "fetch"] + assert len(result["errors"]) == 1 # only the MUTATION_FAILED entry diff --git a/tests/test_compose_introspect.py b/tests/test_compose_introspect.py index f84506f..5dc7a72 100644 --- a/tests/test_compose_introspect.py +++ b/tests/test_compose_introspect.py @@ -243,7 +243,8 @@ async def m(cls, n: int) -> int: schema, '{ Svc { m(n: "abc") } }', ) - assert result["data"] is None + # specs/023: per-field — key nulls with an errors entry. + assert result["data"] == {"Svc": {"m": None}} assert len(result["errors"]) >= 1 @@ -281,5 +282,6 @@ async def test_from_context_bad_value_errors_cleanly(self) -> None: "{ _ContextCoercionService { echo_uuid } }", context={"u": "not_a_uuid"}, ) - assert result["data"] is None + # specs/023: per-field — key nulls with an errors entry. + assert result["data"] == {"_ContextCoercionService": {"echo_uuid": None}} assert "Failed to coerce" in result["errors"][0]["message"] diff --git a/tests/test_compose_mcp_server.py b/tests/test_compose_mcp_server.py index 5083536..6cc6f80 100644 --- a/tests/test_compose_mcp_server.py +++ b/tests/test_compose_mcp_server.py @@ -310,7 +310,7 @@ async def test_missing_required_context_returns_error(self, mcp_server) -> None: "compose_query", {"app_name": "admin", "query": "{ ContextService { actor_name } }"}, ) - assert data["data"] is None + assert data["data"] == {"ContextService": {"actor_name": None}} assert "Required FromContext parameter 'actor'" in data["errors"][0]["message"] async def test_context_extractor_supplies_value(self) -> None: @@ -333,3 +333,26 @@ async def extractor(_request): ) assert data["errors"] == [] assert data["data"]["ContextService"]["actor_name"] == "Eve" + + +class TestLayer3AliasedQuery: + """specs/023: aliases work end-to-end through the MCP tool.""" + + async def test_aliased_fan_out_via_mcp(self, mcp_server) -> None: + data = await _call( + mcp_server, + "compose_query", + { + "app_name": "project", + "query": ( + "{ UserService { " + 'alice: get_user(user_id: 1) { id name } ' + 'ghost: get_user(user_id: 99) { id name } } }' + ), + }, + ) + assert data["errors"] == [] + users = data["data"]["UserService"] + assert set(users) == {"alice", "ghost"} + assert users["alice"]["name"] == "User 1" + assert users["ghost"]["id"] == 99 # fixture's get_user echoes the id diff --git a/tests/test_federation_remote_loader.py b/tests/test_federation_remote_loader.py index 4304a16..7e61ff8 100644 --- a/tests/test_federation_remote_loader.py +++ b/tests/test_federation_remote_loader.py @@ -206,3 +206,80 @@ class T(SQLModel, table=True): br = roots["by_product_id_in"] assert br.arg_name == "product_id_list" assert br.arg_type == "list[int]" + + +# ────────────────────────────────────────────────────────────────────── +# specs/023 US4: the wire never carries aliases (mounter-side boundary gate) +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_wire_never_carries_aliases_flat(): + """Aliased selection keys must not leak into the member-bound query. + + Simulates the post-023 parser tree: sub_fields keyed by RESPONSE key + (alias when present) with FieldSelection.name carrying the real field + name. The renderer must emit original field names only. + """ + target_cls = create_model( + "WireReview", + id=(int | None, None), + product_id=(int | None, None), + title=(str | None, None), + ) + transport = FakeTransport( + {"WireReview": {"by_product_id_in": [{"product_id": 1, "title": "R1"}]}} + ) + loader_cls = create_remote_loader( + typename="WireReview", join_remote="product_id", endpoint="http://reviews", + target_cls=target_cls, transport=transport, is_list=False, + arg_name="product_id_list", + ) + # Response-keyed tree exactly as the new parser would build it. + aliased = FieldSelection( + name="WireReview", + sub_fields={ + "t1": FieldSelection(name="title", alias="t1"), + "pid": FieldSelection(name="product_id", alias="pid"), + }, + ) + loader = loader_cls() + set_remote_selection(loader, aliased) + await loader.load_many([1]) + q = transport.posts[0][1]["query"] + assert "title" in q and "product_id" in q + assert "t1:" not in q and "pid:" not in q + + +@pytest.mark.asyncio +async def test_wire_never_carries_aliases_nested(): + """Nested aliased sub-selections also render with original field names.""" + target_cls = create_model( + "WireUser", + id=(int | None, None), + name=(str | None, None), + ) + transport = FakeTransport( + {"WireUser": {"by_id_in": [{"id": 1, "name": "A"}]}} + ) + loader_cls = create_remote_loader( + typename="WireUser", join_remote="id", endpoint="http://users", + target_cls=target_cls, transport=transport, is_list=False, + arg_name="id_list", + ) + aliased = FieldSelection( + name="WireUser", + sub_fields={ + "name": FieldSelection(name="name"), + "friend": FieldSelection( + name="friend", + sub_fields={"n": FieldSelection(name="name", alias="n")}, + ), + }, + ) + loader = loader_cls() + set_remote_selection(loader, aliased) + await loader.load_many([1]) + q = transport.posts[0][1]["query"] + assert "friend" in q and "name" in q + assert "n:" not in q diff --git a/tests/test_handler.py b/tests/test_handler.py index 2f317ee..4a5164e 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -181,6 +181,21 @@ async def test_rejects_aliases(self, handler: GraphQLHandler) -> None: assert "errors" in result assert any("aliases are not supported" in error["message"] for error in result["errors"]) + @pytest.mark.asyncio + async def test_duplicate_response_key_gets_alias_conflict_code( + self, handler: GraphQLHandler + ) -> None: + """specs/023 FR-007: duplicate response keys carry a machine-readable + code on the entity-first path too (aligned with compose).""" + result = await handler.execute( + "{ HandlerTestUser { get_all { id } get_all { name } } }" + ) + + assert "errors" in result + error = result["errors"][0] + assert "Response key conflict" in error["message"] + assert error["extensions"]["code"] == "ALIAS_CONFLICT" + # ============================================================================ # Tests for Relationship-based entity discovery diff --git a/tests/test_query_executor.py b/tests/test_query_executor.py index 261d76c..5ff65ff 100644 --- a/tests/test_query_executor.py +++ b/tests/test_query_executor.py @@ -4,7 +4,7 @@ import pytest from graphql import DocumentNode, parse -from sqlmodel import SQLModel, select +from sqlmodel import Field, SQLModel, select from nexusx.decorator import mutation, query from nexusx.execution.query_executor import QueryExecutor @@ -265,20 +265,21 @@ async def boom(cls): [FixtureUser], ) - assert result["data"] == {"FixtureUser": None} + # specs/023 D4: the failed method nulls only its own key. + assert result["data"] == {"FixtureUser": {"boom": None}} err = result["errors"][0] assert err["message"] == "kaboom" assert err["path"] == ["FixtureUser", "boom"] assert err["extensions"] == {"code": "RESOLVER_ERROR"} - async def test_execute_resolver_error_nulls_group_despite_healthy_sibling( + async def test_execute_resolver_error_keeps_healthy_sibling( self, ): - """A failed method nulls the whole group even when a sibling ran fine. + """specs/023 D4: a failed method nulls only its own response key. - Method return types are non-null, so per GraphQL null propagation - the error spreads past the group rather than returning partial - sibling data. + Query groups keep executing siblings past a failure — already + computed results are never erased (replaces pre-023 whole-group + nulling). """ executor = _make_executor() @@ -309,7 +310,7 @@ async def boom(cls): [FixtureUser], ) - assert result["data"] == {"FixtureUser": None} + assert result["data"] == {"FixtureUser": {"boom": None, "ok": []}} assert result["errors"][0]["extensions"] == {"code": "RESOLVER_ERROR"} async def test_execute_handles_exception_in_method_logs_traceback(self, caplog): @@ -888,3 +889,355 @@ def test_get_relationship_names(self): rel_names = get_relationship_names(FixtureTask) assert "sprint" in rel_names assert "owner" in rel_names + + +# ────────────────────────────────────────────────────────── +# specs/023 US2: aliased query methods (entity-first path) +# ────────────────────────────────────────────────────────── + + +class _AliasStats: + calls: list[str] = [] + + +class AliasProbe(SQLModel, table=False): + """Static @query with call recording — no DB roundtrip needed.""" + + @query + async def probe(cls, tag: str = "x"): + """Probe.""" + _AliasStats.calls.append(f"probe:{tag}") + return [ + FixtureUser(id=1, name=tag, email=f"{tag}@t"), + FixtureUser(id=2, name=f"{tag}2", email=f"{tag}2@t"), + ] + + +class TestAliasQuerySemantics: + @pytest.mark.usefixtures("test_db") + async def test_two_aliases_both_execute_and_keyed_by_alias(self): + """a/b aliases of one method → 2 executions, 2 response keys.""" + _AliasStats.calls.clear() + executor = _make_executor() + method = _get_bound_method(AliasProbe, "probe") + query_methods = {"AliasProbe": {"probe": (FixtureUser, method)}} + document, parsed = _parse( + '{ AliasProbe { a: probe(tag: "hi") { id name } ' + 'b: probe(tag: "lo") { id name } } }' + ) + result = await executor.execute_query( + document, None, None, parsed, query_methods, + {}, [FixtureUser], + ) + assert result.get("errors") is None or result["errors"] == [] + data = result["data"]["AliasProbe"] + assert set(data) == {"a", "b"} + assert {row["name"] for row in data["a"]} == {"hi", "hi2"} + assert {row["name"] for row in data["b"]} == {"lo", "lo2"} + assert sorted(_AliasStats.calls) == ["probe:hi", "probe:lo"] + + @pytest.mark.usefixtures("test_db") + async def test_projection_isolated_per_alias(self): + """Each alias serializes only the sub-fields it declared.""" + _AliasStats.calls.clear() + executor = _make_executor() + method = _get_bound_method(AliasProbe, "probe") + query_methods = {"AliasProbe": {"probe": (FixtureUser, method)}} + document, parsed = _parse( + '{ AliasProbe { a: probe(tag: "p") { id } ' + 'b: probe(tag: "p") { id name } } }' + ) + result = await executor.execute_query( + document, None, None, parsed, query_methods, + {}, [FixtureUser], + ) + data = result["data"]["AliasProbe"] + assert set(data["a"][0]) == {"id"} + assert set(data["b"][0]) == {"id", "name"} + + @pytest.mark.usefixtures("test_db") + async def test_same_method_same_args_runs_twice(self): + """FR-011 query half: no method-level dedup on the entity-first path.""" + _AliasStats.calls.clear() + executor = _make_executor() + method = _get_bound_method(AliasProbe, "probe") + query_methods = {"AliasProbe": {"probe": (FixtureUser, method)}} + document, parsed = _parse( + '{ AliasProbe { a: probe(tag: "s") { id } b: probe(tag: "s") { id } } }' + ) + result = await executor.execute_query( + document, None, None, parsed, query_methods, + {}, [FixtureUser], + ) + assert _AliasStats.calls == ["probe:s", "probe:s"] + + @pytest.mark.asyncio + async def test_handler_level_alias_allowed(self): + """T012: GraphQLHandler must stop rejecting aliases (query level).""" + from nexusx.handler import GraphQLHandler + + _AliasStats.calls.clear() + + class AliasEntity(SQLModel, table=True): + __tablename__ = "alias_entity_probe" + id: int | None = Field(default=None, primary_key=True) + name: str + + @query + async def probe(cls, tag: str = "x"): + """Probe.""" + _AliasStats.calls.append(f"probe:{tag}") + return [cls(id=1, name=tag)] + + handler = GraphQLHandler( + er_manager=ErManager( + session_factory=get_test_session_factory(), + entities=[AliasEntity], + ) + ) + result = await handler.execute( + '{ AliasEntity { a: probe(tag: "h") { id name } ' + 'b: probe(tag: "l") { id name } } }' + ) + assert result.get("errors") is None or result["errors"] == [] + assert set(result["data"]["AliasEntity"]) == {"a", "b"} + assert result["data"]["AliasEntity"]["a"][0]["name"] == "h" + assert result["data"]["AliasEntity"]["b"][0]["name"] == "l" + + +class TestMutationThreeState: + """specs/023 US3: entity-first mutation aliases + three-state feedback, + aligned with the compose path (clarify Q1: both paths unified).""" + + async def test_aliased_mutations_all_execute_and_keyed_by_alias(self): + executor = _make_executor() + calls: list[str] = [] + + class M(SQLModel, table=False): + @mutation + async def write(cls, tag: str = "x"): + """Write.""" + calls.append(tag) + return FixtureUser(id=len(calls), name=tag, email=f"{tag}@t") + + mutation_methods = {"FixtureUser": {"write": (FixtureUser, _get_bound_method(M, "write"))}} + document, parsed = _parse( + 'mutation { FixtureUser { ' + 'a: write(tag: "1") { id } b: write(tag: "2") { id } ' + 'c: write(tag: "3") { id } } }' + ) + result = await executor.execute_query( + document, None, None, parsed, {}, mutation_methods, [FixtureUser], + ) + assert result.get("errors") is None or result["errors"] == [] + data = result["data"]["FixtureUser"] + assert list(data) == ["a", "b", "c"] # declaration order + assert [data[k]["id"] for k in "abc"] == [1, 2, 3] + assert calls == ["1", "2", "3"] + + async def test_mutation_failure_keeps_prior_and_skips_rest(self): + executor = _make_executor() + calls: list[str] = [] + + class M(SQLModel, table=False): + @mutation + async def flaky(cls, tag: str = "x"): + """Flaky.""" + calls.append(tag) + if tag == "fail": + raise RuntimeError("exploded") + return FixtureUser(id=1, name=tag, email=f"{tag}@t") + + mutation_methods = {"FixtureUser": {"flaky": (FixtureUser, _get_bound_method(M, "flaky"))}} + document, parsed = _parse( + 'mutation { FixtureUser { ' + 'first: flaky(tag: "ok") { id } ' + 'bad: flaky(tag: "fail") { id } ' + 'last: flaky(tag: "never") { id } } }' + ) + result = await executor.execute_query( + document, None, None, parsed, {}, mutation_methods, [FixtureUser], + ) + data = result["data"]["FixtureUser"] + assert data["first"]["id"] == 1 # prior result survives + assert data["bad"] is None + assert data["last"] is None # fail-stop + codes = [e["extensions"]["code"] for e in result["errors"]] + assert codes == ["RESOLVER_ERROR", "SKIPPED_PRIOR_FAILURE"] + assert calls == ["ok", "fail"] # 'never' never executed + + +class TestFederationRemoteFieldAliasRejected: + """specs/023 FR-009: aliases on remote-relationship (or any nested + relationship) fields are rejected pre-execution — the wire gate's + mounter-side complement.""" + + async def test_relationship_field_alias_rejected_before_execution(self): + executor = _make_executor() + calls: list[str] = [] + + class P(SQLModel, table=False): + @query + async def get(cls): + """Get.""" + calls.append("ran") + return [FixtureUser(id=1, name="A", email="a@t")] + + query_methods = {"FixtureUser": {"get": (FixtureUser, _get_bound_method(P, "get"))}} + # 'tasks' is a relationship field on FixtureUser; aliasing it is + # rejected at validation time (before the method ever runs). + document, parsed = _parse("{ FixtureUser { get { id t: tasks { id } } } }") + result = await executor.execute_query( + document, None, None, parsed, query_methods, {}, [FixtureUser], + ) + assert calls == [] # rejected before execution + assert any("aliases are not supported" in e["message"] + for e in result["errors"]) + # The rejected method is skipped: the group serializes as an empty + # object (NOT null — group-level nulling was dropped in specs/023 D4). + assert result["data"] == {"FixtureUser": {}} + + +class TestErrorPathResponseKeys: + """specs/023 P2: error paths use RESPONSE keys (alias or name), matching + the data dict keys, per the GraphQL spec — clients locate errors inside + the data they actually received. Messages keep ORIGINAL names (better for + humans looking up the schema); only ``path`` carries response keys.""" + + @pytest.mark.usefixtures("test_db") + async def test_validation_error_path_uses_method_alias(self): + executor = _make_executor() + + class P(SQLModel, table=False): + @query + async def get(cls): + """Get.""" + return [FixtureUser(id=1, name="A", email="a@t")] + + query_methods = {"FixtureUser": {"get": (FixtureUser, _get_bound_method(P, "get"))}} + document, parsed = _parse("{ FixtureUser { a: get { id bogus } } }") + result = await executor.execute_query( + document, None, None, parsed, query_methods, {}, [FixtureUser], + ) + assert [e["path"] for e in result["errors"]] == [ + ["FixtureUser", "a", "bogus"], + ] + + @pytest.mark.usefixtures("test_db") + async def test_resolver_error_path_uses_group_alias(self): + executor = _make_executor() + + class P(SQLModel, table=False): + @query + async def boom(cls): + """Boom.""" + raise RuntimeError("kaboom") + + query_methods = {"FixtureUser": {"boom": (FixtureUser, _get_bound_method(P, "boom"))}} + document, parsed = _parse("{ t: FixtureUser { b: boom { id } } }") + result = await executor.execute_query( + document, None, None, parsed, query_methods, {}, [FixtureUser], + ) + assert list(result["data"]) == ["t"] # data keyed by response key + err = result["errors"][0] + assert err["path"] == ["t", "b"] + assert err["extensions"]["code"] == "RESOLVER_ERROR" + + async def test_unknown_method_error_path_uses_aliases(self): + executor = _make_executor() + query_methods = {"FixtureUser": {}} + document, parsed = _parse("{ t: FixtureUser { a: nope { id } } }") + result = await executor.execute_query( + document, None, None, parsed, query_methods, {}, [FixtureUser], + ) + assert "nope" in result["errors"][0]["message"] # original name + assert result["errors"][0]["path"] == ["t", "a"] # response keys +class TestOperationScopeFailStop: + """specs/023 FR-006 (review follow-up): mutation fail-stop propagates + across entity-group boundaries (GraphQL mutations are serial at + operation level). Later groups' mutations are skipped and marked + SKIPPED_PRIOR_FAILURE; query groups are unaffected.""" + + async def test_later_group_mutations_skipped_after_earlier_failure(self): + executor = _make_executor() + calls: list[str] = [] + + class Bad(SQLModel, table=False): + @mutation + async def bad(cls): + """Bad.""" + calls.append("bad") + raise RuntimeError("boom") + + class Write(SQLModel, table=False): + @mutation + async def write(cls): + """Write.""" + calls.append("write") + return FixtureUser(id=9, name="w", email="w@t") + + mutation_methods = { + "FixtureUser": { + "bad": (FixtureUser, _get_bound_method(Bad, "bad")), + "write": (FixtureUser, _get_bound_method(Write, "write")), + } + } + document, parsed = _parse( + "mutation { a: FixtureUser { bad { id } } " + "b: FixtureUser { write { id } } }" + ) + result = await executor.execute_query( + document, None, None, parsed, {}, mutation_methods, [FixtureUser], + ) + assert result["data"] == { + "a": {"bad": None}, + "b": {"write": None}, + } + assert [ + (e["extensions"]["code"], e["path"]) for e in result["errors"] + ] == [ + ("RESOLVER_ERROR", ["a", "bad"]), + ("SKIPPED_PRIOR_FAILURE", ["b", "write"]), + ] + assert calls == ["bad"] # 'write' never executed + + @pytest.mark.usefixtures("test_db") + async def test_query_groups_unaffected_by_abort_flag(self): + """The abort flag only gates mutations — a query group in the same + document still runs (FR-005: queries have no fail-stop).""" + executor = _make_executor() + calls: list[str] = [] + + class Bad(SQLModel, table=False): + @mutation + async def bad(cls): + """Bad.""" + calls.append("bad") + raise RuntimeError("boom") + + class Get(SQLModel, table=False): + @query + async def get(cls): + """Get.""" + calls.append("get") + return [FixtureUser(id=1, name="A", email="a@t")] + + query_methods = { + "FixtureUser": {"get": (FixtureUser, _get_bound_method(Get, "get"))} + } + mutation_methods = { + "FixtureUser": {"bad": (FixtureUser, _get_bound_method(Bad, "bad"))} + } + document, parsed = _parse( + "mutation { FixtureUser { bad { id } } } " + "query { q: FixtureUser { get { id } } }" + ) + # (the alias keeps the two operations' top-level keys distinct — + # parse_document merges all operations into one response-key dict) + result = await executor.execute_query( + document, None, None, parsed, query_methods, + mutation_methods, [FixtureUser], + ) + assert calls == ["bad", "get"] # the query ran despite the abort + assert result["data"]["q"] == {"get": [{"id": 1}]} + assert result["errors"][0]["extensions"]["code"] == "RESOLVER_ERROR" diff --git a/tests/test_query_parser.py b/tests/test_query_parser.py index 83ddc82..a968d12 100644 --- a/tests/test_query_parser.py +++ b/tests/test_query_parser.py @@ -128,3 +128,131 @@ def test_with_values(self): assert sel.name == "users" assert sel.alias == "allUsers" assert sel.arguments == {"limit": 10} + + +# ────────────────────────────────────────────────────────────────────── +# specs/023 US2: alias key semantics + response-key conflict detection +# ────────────────────────────────────────────────────────────────────── + + +class TestAliasKeySemantics: + """sub_fields keys are RESPONSE keys (alias or name); lookups use .name.""" + + def test_aliased_fields_are_kept_separately(self): + """Two aliased invocations of the same field must both survive.""" + parser = QueryParser() + result = parser.parse( + '{ S { a: f(x: 1) { id } b: f(x: 2) { id title } } }' + ) + svc = result["S"] + # Both response keys survive (6.1.2 kept only the last one). + assert list(svc.sub_fields) == ["a", "b"] + # Lookup identity stays the original field name. + assert svc.sub_fields["a"].name == "f" + assert svc.sub_fields["b"].name == "f" + # Arguments stay per-alias. + assert svc.sub_fields["a"].arguments == {"x": 1} + assert svc.sub_fields["b"].arguments == {"x": 2} + # Projections stay per-alias. + assert list(svc.sub_fields["a"].sub_fields) == ["id"] + assert list(svc.sub_fields["b"].sub_fields) == ["id", "title"] + + def test_alias_metadata_is_populated(self): + parser = QueryParser() + result = parser.parse("{ S { a: f { id } } }") + sel = result["S"].sub_fields["a"] + assert sel.alias == "a" + assert sel.name == "f" + + def test_unaliased_key_unchanged(self): + """No alias → key stays the field name (backward compatible).""" + parser = QueryParser() + result = parser.parse("{ S { f { id } } }") + assert list(result["S"].sub_fields) == ["f"] + assert result["S"].sub_fields["f"].alias is None + + def test_mixed_alias_and_plain_coexist(self): + parser = QueryParser() + result = parser.parse("{ S { f { id } a: g { id } } }") + assert set(result["S"].sub_fields) == {"f", "a"} + + +class TestResponseKeyConflicts: + """Duplicate response keys at one level raise (no silent dedup, no merging).""" + + def test_duplicate_alias_rejected(self): + parser = QueryParser() + with pytest.raises(ValueError, match="conflict"): + parser.parse("{ S { a: f(x: 1) { id } a: g { id } } }") + + def test_alias_collides_with_field_name_rejected(self): + parser = QueryParser() + with pytest.raises(ValueError, match="conflict"): + parser.parse("{ S { a: f { id } a { id } } }") + + def test_plain_duplicate_field_rejected_no_merging(self): + """`f { x } f { y }` is legal field-merging per spec — we reject it + (specs/023 clarify: response keys must be unique; no merging).""" + parser = QueryParser() + with pytest.raises(ValueError, match="conflict"): + parser.parse("{ S { f { x } f { y } } }") + + def test_nested_level_conflict_rejected(self): + parser = QueryParser() + with pytest.raises(ValueError, match="conflict"): + parser.parse("{ S { f { t: id t: name } } }") + + def test_top_level_group_duplicate_rejected(self): + """`{ A {...} A {...} }` — same family of bug at operation level.""" + parser = QueryParser() + with pytest.raises(ValueError, match="conflict"): + parser.parse("{ S { f { id } } S { g { id } } }") + + def test_same_name_in_different_groups_ok(self): + """Conflicts are per-level: same field name under different parents is fine.""" + parser = QueryParser() + result = parser.parse("{ A { f { id } } B { f { id } } }") + assert set(result) == {"A", "B"} + + +class TestTopLevelAlias: + """specs/023: entity-group / service level aliases follow the same + response-key semantics (key = alias, lookup via .name).""" + + def test_top_level_alias_keyed_by_alias(self): + parser = QueryParser() + result = parser.parse("{ t: TaskService { list_tasks { id } } }") + assert list(result) == ["t"] + assert result["t"].name == "TaskService" + assert result["t"].alias == "t" + assert "list_tasks" in result["t"].sub_fields + + def test_two_aliased_groups_coexist(self): + parser = QueryParser() + result = parser.parse( + "{ a: TaskService { list_tasks { id } } " + "b: TaskService { get_task(task_id: 1) { id } } }" + ) + assert set(result) == {"a", "b"} + assert result["a"].name == "TaskService" + assert result["b"].name == "TaskService" + + +class TestValidateNoAliasesGuard: + """specs/023: ``validate_no_aliases`` is no longer called internally + (the executors support method-level aliases) but stays public as an + optional user-side guard — smoke-test it so it cannot rot silently.""" + + def test_rejects_aliased_query(self): + parser = QueryParser() + with pytest.raises(ValueError, match="aliases are not supported"): + parser.validate_no_aliases("{ S { a: f { id } } }") + + def test_rejects_nested_alias(self): + parser = QueryParser() + with pytest.raises(ValueError, match="aliases are not supported"): + parser.validate_no_aliases("{ S { f { a: id } } }") + + def test_accepts_plain_query(self): + parser = QueryParser() + parser.validate_no_aliases("{ S { f { id } } }") # must not raise diff --git a/tests/test_selection.py b/tests/test_selection.py index 0998a1b..ae1e88d 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -109,3 +109,28 @@ class MyDTO(BaseModel): result = _infer_runtime_annotation([MyDTO(x=1), None]) assert result == list[MyDTO | None] + + +# ────────────────────────────────────────────────────────────────────── +# specs/023 FR-009: field aliases in a --select projection are rejected +# ────────────────────────────────────────────────────────────────────── + + +class TestSelectionAliasRejection: + def test_top_level_alias_rejected(self): + from nexusx.use_case.selection import parse_selection + + with pytest.raises(Exception, match="aliases are not supported"): + parse_selection("t1: title") + + def test_nested_alias_rejected(self): + from nexusx.use_case.selection import parse_selection + + with pytest.raises(Exception, match="aliases are not supported"): + parse_selection("id owner { n: name }") + + def test_plain_selection_still_works(self): + from nexusx.use_case.selection import parse_selection + + root = parse_selection("id title") + assert set(root.sub_fields) == {"id", "title"}