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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
{
"feature_directory": "specs/022-voyager-composed-clusters"
}
{"feature_directory": "specs/023-gql-alias-support"}
68 changes: 68 additions & 0 deletions docs/guide/graphql-aliases.md
Original file line number Diff line number Diff line change
@@ -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 内部不再调用——需要禁用别名的自定义防线可继续使用
36 changes: 36 additions & 0 deletions specs/023-gql-alias-support/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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 间耦合
34 changes: 34 additions & 0 deletions specs/023-gql-alias-support/contracts/graphql-alias-behavior.md
Original file line number Diff line number Diff line change
@@ -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 说明
75 changes: 75 additions & 0 deletions specs/023-gql-alias-support/data-model.md
Original file line number Diff line number Diff line change
@@ -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": {
"<Service|Entity>": {
"<响应键>": <结果> # 已成功:正常值
# 失败/跳过:null
}
},
"errors": [
{
"message": "<人类可读>",
"path": ["<Service|Entity>", "<响应键>"],
"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)。
78 changes: 78 additions & 0 deletions specs/023-gql-alias-support/plan.md
Original file line number Diff line number Diff line change
@@ -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**

无违规,不适用。
Loading