diff --git a/CLAUDE.md b/CLAUDE.md index 889fdea..e7dc1f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ The main `graphQlQueryToJson` function: ### Testing - Comprehensive Jest test suite in `src/test/full_functionality.spec.ts` - README examples validation tests in `src/test/readme_examples.spec.ts` -- Tests cover queries, mutations, subscriptions, aliases, enums, variables, scalar fields with arguments, float arguments (including edge cases), and error cases +- Tests cover queries, mutations, subscriptions, aliases, enums, variables, scalar fields with arguments, float arguments (including edge cases), inline fragments, and error cases - Tests run against compiled dist/ files, so always build before testing - To run a single test file: `npm test -- --testNamePattern="specific test name"` - Current coverage: 99% statements, 98% branches (line 111 in index.ts is unreachable dead code) @@ -48,6 +48,7 @@ The main `graphQlQueryToJson` function: The library processes GraphQL Abstract Syntax Trees (AST) from the `graphql` library. Key AST node types handled: - `OperationDefinition` - Query/mutation/subscription operations - `Field` - Individual field selections with optional arguments and aliases +- `InlineFragment` - Inline fragments for conditional type-based field selection - `Argument` - Field arguments with various value types (string, int, float, enum, object, list, variable) - `SelectionSet` - Groups of field selections @@ -58,6 +59,7 @@ The library processes GraphQL Abstract Syntax Trees (AST) from the `graphql` lib - **Aliases**: Aliased fields get `__aliasFor` metadata: `alias: { __aliasFor: "originalField" }` - **Variables**: Replaced with actual values using `replaceVariables()` function - **Enums**: Wrapped in `EnumType` objects from json-to-graphql-query library (except in arrays where they remain strings) +- **Inline Fragments**: Single fragment becomes `__on: { __typeName: "TypeName", ...fields }`, multiple fragments become `__on: [{ __typeName: "Type1", ...}, ...]` ### Error Handling - Validates that all variables referenced in query are provided diff --git a/README.md b/README.md index 5f2480d..0d41deb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A TypeScript library that converts GraphQL query and mutation strings into struc - ✅ **Variable Handling**: Complete variable substitution with validation - ✅ **Arguments**: All argument types (strings, integers, floats, objects, arrays, enums) - ✅ **Aliases**: Field aliasing with metadata preservation +- ✅ **Inline Fragments**: Complete support for conditional type-based field selection - ✅ **Type Safety**: Full TypeScript support with comprehensive type definitions - ✅ **Error Handling**: Descriptive error messages for malformed queries and missing variables - ✅ **Framework Agnostic**: Works with any JavaScript/TypeScript environment @@ -83,6 +84,7 @@ The library follows predictable transformation patterns: | **Aliases** | Field renaming + `__aliasFor` | `renamed: user` → `renamed: { __aliasFor: "user" }` | | **Variables** | Substituted values | `$userId` → actual variable value | | **Enums** | `EnumType` wrapper | `status: ACTIVE` → `status: { "value": "ACTIVE" }` | +| **Inline Fragments** | `__on` property | `... on User { name }` → `__on: { __typeName: "User", name: true }` | ## Comprehensive Examples @@ -568,6 +570,134 @@ const result = graphQlQueryToJson(query) } ``` +### Inline Fragments + +```ts +// Single inline fragment +const query = ` +query { + posts { + title + ... on TextPost { + content + wordCount + } + } +} +` + +const result = graphQlQueryToJson(query) + +// Output: +{ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: true, + wordCount: true + } + } + } +} +``` + +```ts +// Multiple inline fragments +const query = ` +query { + media { + ... on TextPost { + content + author { + name + } + } + ... on ImagePost { + imageUrl + altText + } + ... on VideoPost { + videoUrl + duration + } + } +} +` + +const result = graphQlQueryToJson(query) + +// Output: +{ + query: { + media: { + __on: [ + { + __typeName: "TextPost", + content: true, + author: { + name: true + } + }, + { + __typeName: "ImagePost", + imageUrl: true, + altText: true + }, + { + __typeName: "VideoPost", + videoUrl: true, + duration: true + } + ] + } + } +} +``` + +```ts +// Inline fragments with arguments and variables +const query = ` +query GetPosts($limit: Int!) { + posts { + title + ... on TextPost { + comments(limit: $limit) { + text + author { + name + } + } + } + } +} +` + +const result = graphQlQueryToJson(query, { + variables: { limit: 5 } +}) + +// Output: +{ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + comments: { + __args: { limit: 5 }, + text: true, + author: { + name: true + } + } + } + } + } +} +``` + ### Subscriptions ```ts @@ -686,11 +816,11 @@ const result = graphQlQueryToJson(subscription) While the library supports the core GraphQL features, there are some limitations: ### Fragment Support +- **Inline Fragments**: ✅ **Fully Supported** (e.g., `... on TypeName`) - **Named Fragments**: Not supported due to multiple definition restriction -- **Inline Fragments**: Not supported (e.g., `... on TypeName`) ```ts -// ❌ This will throw an error +// ❌ Named fragments still throw an error const queryWithFragment = ` query { user { @@ -705,7 +835,7 @@ fragment UserFields on User { ` // Throws: "The parsed query has more than one set of definitions" -// ❌ This will cause a runtime error +// ✅ Inline fragments work perfectly const queryWithInlineFragment = ` query { search { @@ -718,7 +848,7 @@ query { } } ` -// Causes: Cannot read properties of undefined +// Output: { query: { search: { __on: [...] } } } ``` ### Directives diff --git a/src/index.ts b/src/index.ts index 823b2c9..2b61598 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,16 +27,23 @@ interface Argument { interface Selection { kind: string - alias: { + alias?: { kind: string value: string } - name: { + name?: { kind: string value: string } arguments?: Argument[] selectionSet?: SelectionSet + typeCondition?: { + kind: string + name: { + kind: string + value: string + } + } } interface SelectionSet { @@ -128,30 +135,63 @@ const getArguments = (args) => { } const getSelections = (selections: Selection[]) => { - const selObj = {} + const selObj: any = {} + const inlineFragments = [] + selections.forEach((selection) => { - const selectionHasAlias = selection.alias - const selectionName = selectionHasAlias - ? selection.alias.value - : selection.name.value - if (selection.selectionSet) { - selObj[selectionName] = getSelections( + if (selection.kind === "InlineFragment") { + // Handle inline fragments + const typeName = selection.typeCondition.name.value + const fragmentSelections = getSelections( selection.selectionSet.selections, ) - if (selectionHasAlias) { - selObj[selection.alias.value].__aliasFor = selection.name.value + inlineFragments.push({ + __typeName: typeName, + ...fragmentSelections, + }) + } else { + // Handle regular fields + const selectionHasAlias = selection.alias + const selectionName = selectionHasAlias + ? selection.alias.value + : selection.name.value + if (selection.selectionSet) { + selObj[selectionName] = getSelections( + selection.selectionSet.selections, + ) + if (selectionHasAlias) { + selObj[selection.alias.value].__aliasFor = + selection.name.value + } } - } - if (selection.arguments.length > 0) { - if (!selObj[selectionName]) { - selObj[selectionName] = {} + if (selection.arguments && selection.arguments.length > 0) { + if (!selObj[selectionName]) { + selObj[selectionName] = {} + } + selObj[selectionName].__args = getArguments(selection.arguments) + } + if ( + !selection.selectionSet && + (!selection.arguments || selection.arguments.length === 0) + ) { + if (selectionHasAlias) { + selObj[selectionName] = {__aliasFor: selection.name.value} + } else { + selObj[selectionName] = true + } } - selObj[selectionName].__args = getArguments(selection.arguments) - } - if (!selection.selectionSet && selection.arguments.length === 0) { - selObj[selectionName] = true } }) + + // Add inline fragments to the result + if (inlineFragments.length > 0) { + if (inlineFragments.length === 1) { + selObj.__on = inlineFragments[0] + } else { + selObj.__on = inlineFragments + } + } + return selObj } diff --git a/src/test/full_functionality.spec.ts b/src/test/full_functionality.spec.ts index 0a973db..87f49e6 100644 --- a/src/test/full_functionality.spec.ts +++ b/src/test/full_functionality.spec.ts @@ -695,7 +695,7 @@ describe("Subscriptions", () => { }).toThrow("The parsed query has more than one set of definitions") }) - it("Should throw error for subscription with inline fragments", () => { + it("Should handle subscription with inline fragments", () => { const subscriptionWithInlineFragment = ` subscription { messageAdded { @@ -711,9 +711,25 @@ describe("Subscriptions", () => { } } ` - expect(() => { - graphQlQueryToJson(subscriptionWithInlineFragment) - }).toThrow() + expect(graphQlQueryToJson(subscriptionWithInlineFragment)).toEqual({ + subscription: { + messageAdded: { + id: true, + content: true, + __on: [ + { + __typeName: "TextMessage", + text: true, + }, + { + __typeName: "ImageMessage", + imageUrl: true, + caption: true, + }, + ], + }, + }, + }) }) }) @@ -1382,4 +1398,282 @@ describe("Edge Cases and Additional Coverage", () => { }, }) }) + + describe("Inline Fragments", () => { + it("Single inline fragment", () => { + const query = ` + query { + posts { + title + ... on TextPost { + content + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: true, + }, + }, + }, + }) + }) + + it("Multiple inline fragments", () => { + const query = ` + query { + posts { + title + ... on TextPost { + content + wordCount + } + ... on ImagePost { + imageUrl + altText + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: [ + { + __typeName: "TextPost", + content: true, + wordCount: true, + }, + { + __typeName: "ImagePost", + imageUrl: true, + altText: true, + }, + ], + }, + }, + }) + }) + + it("Inline fragment with nested selections", () => { + const query = ` + query { + posts { + title + ... on TextPost { + content + author { + name + bio + } + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: true, + author: { + name: true, + bio: true, + }, + }, + }, + }, + }) + }) + + it("Inline fragment with arguments", () => { + const query = ` + query { + posts { + title + ... on TextPost { + content + comments(limit: 5) { + text + } + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: true, + comments: { + __args: { + limit: 5, + }, + text: true, + }, + }, + }, + }, + }) + }) + + it("Nested inline fragments", () => { + const query = ` + query { + media { + ... on Post { + title + ... on TextPost { + content + } + ... on ImagePost { + imageUrl + } + } + ... on Comment { + text + author { + name + } + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + media: { + __on: [ + { + __typeName: "Post", + title: true, + __on: [ + { + __typeName: "TextPost", + content: true, + }, + { + __typeName: "ImagePost", + imageUrl: true, + }, + ], + }, + { + __typeName: "Comment", + text: true, + author: { + name: true, + }, + }, + ], + }, + }, + }) + }) + + it("Inline fragment with enum arguments", () => { + const query = ` + query { + posts { + title + ... on TextPost { + content(format: MARKDOWN) { + rendered + } + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: { + __args: { + format: new EnumType("MARKDOWN"), + }, + rendered: true, + }, + }, + }, + }, + }) + }) + + it("Inline fragment with variables", () => { + const query = ` + query GetPosts($limit: Int!) { + posts { + title + ... on TextPost { + comments(limit: $limit) { + text + } + } + } + } + ` + expect(graphQlQueryToJson(query, {variables: {limit: 10}})).toEqual( + { + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + comments: { + __args: { + limit: 10, + }, + text: true, + }, + }, + }, + }, + }, + ) + }) + + it("Inline fragment with aliases", () => { + const query = ` + query { + posts { + title + ... on TextPost { + textContent: content + authorName: author { + name + } + } + } + } + ` + expect(graphQlQueryToJson(query)).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + textContent: { + __aliasFor: "content", + }, + authorName: { + __aliasFor: "author", + name: true, + }, + }, + }, + }, + }) + }) + }) }) diff --git a/src/test/readme_examples.spec.ts b/src/test/readme_examples.spec.ts index 6614aec..d882d74 100644 --- a/src/test/readme_examples.spec.ts +++ b/src/test/readme_examples.spec.ts @@ -615,4 +615,126 @@ subscription { }) }) }) + + describe("Inline Fragments", () => { + it("Single inline fragment", () => { + const query = ` +query { + posts { + title + ... on TextPost { + content + wordCount + } + } +} +` + + const result = graphQlQueryToJson(query) + + expect(result).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + content: true, + wordCount: true, + }, + }, + }, + }) + }) + + it("Multiple inline fragments", () => { + const query = ` +query { + media { + ... on TextPost { + content + author { + name + } + } + ... on ImagePost { + imageUrl + altText + } + ... on VideoPost { + videoUrl + duration + } + } +} +` + + const result = graphQlQueryToJson(query) + + expect(result).toEqual({ + query: { + media: { + __on: [ + { + __typeName: "TextPost", + content: true, + author: { + name: true, + }, + }, + { + __typeName: "ImagePost", + imageUrl: true, + altText: true, + }, + { + __typeName: "VideoPost", + videoUrl: true, + duration: true, + }, + ], + }, + }, + }) + }) + + it("Inline fragments with arguments and variables", () => { + const query = ` +query GetPosts($limit: Int!) { + posts { + title + ... on TextPost { + comments(limit: $limit) { + text + author { + name + } + } + } + } +} +` + + const result = graphQlQueryToJson(query, { + variables: {limit: 5}, + }) + + expect(result).toEqual({ + query: { + posts: { + title: true, + __on: { + __typeName: "TextPost", + comments: { + __args: {limit: 5}, + text: true, + author: { + name: true, + }, + }, + }, + }, + }, + }) + }) + }) })