Files
web/packages/trpc/lib/graphql/validate.test.ts
Joakim Jäderberg bc5a606289 Merged in feature/turbopack (pull request #3117)
Feature/turbopack

* .

* .

* pin import-in-the-middle

* update marker

* revert back to using *.graphql.ts


Approved-by: Linus Flood
2025-11-11 09:51:40 +00:00

333 lines
8.7 KiB
TypeScript

import fg from "fast-glob"
import { Kind } from "graphql"
import gql from "graphql-tag"
import path from "path"
import { describe, expect, it } from "vitest"
import type { DocumentNode } from "graphql"
const repoRoot = path.resolve(__dirname, "..", "..", "..")
const fragmentFiles = await fg("**/Fragments/**/*.graphql.ts", {
cwd: repoRoot,
absolute: true,
})
const queryFiles = await fg("**/Query/**/*.graphql.ts", {
cwd: repoRoot,
absolute: true,
})
describe("GraphQL Validate", () => {
it("should validate a simple query", async () => {
const document = gql`
fragment Dance on DanceType {
style
}
`
const validationResult = validateFragments(document)
expect(validationResult.valid).toBe(true)
})
it("should validate a simple query", async () => {
const doc = gql`
fragment Dance on DanceType {
style
}
`
expect(validateFragments(doc)).toEqual({
valid: true,
hasOperation: false,
missing: [],
unused: ["Dance"],
})
})
it("should throw when we pass a broken fragment", async () => {
expect(
() => gql`
fraagment Dance on DanceType {
style
}
`
).toThrow()
})
it("should throw when we pass a query with invalid syntax", async () => {
expect(
() => gql`
queery Dance {
style
}
`
).toThrow()
})
it("should not be valid when we have a unused fragment", async () => {
const doc = gql`
query Dance {
style
}
fragment UnusedFragment on DanceType {
id
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(false)
})
it("should not be valid when using a non-existent fragment", async () => {
const doc = gql`
query Dance {
style
...NonExistingFragment
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(false)
})
it("should be valid when file contains only fragments without internal fragment references", async () => {
const doc = gql`
fragment UserInfo on User {
id
name
email
}
fragment PostInfo on Post {
id
title
content
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(true)
expect(validationResult.missing).toEqual([])
expect(validationResult.unused).toEqual(["UserInfo", "PostInfo"]) // Fragments are unused but that's OK when there are no operations
})
it("should not be valid when fragments reference undefined fragments", async () => {
const doc = gql`
fragment UserInfo on User {
id
name
...UndefinedFragment
}
fragment PostInfo on Post {
id
title
content
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(false)
expect(validationResult.missing).toEqual(["UndefinedFragment"])
})
it("should be valid when fragments reference other fragments defined in the same file", async () => {
const doc = gql`
fragment UserDetails on User {
id
name
...ContactInfo
}
fragment ContactInfo on User {
email
phone
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(true)
expect(validationResult.missing).toEqual([])
// ContactInfo is used by UserDetails, but UserDetails itself is unused
expect(validationResult.unused).toEqual(["UserDetails"])
})
it.each(fragmentFiles)("validates fragment %s", async (file) => {
const imported = await import(file)
if (imported.default) {
const graphqlDocument = imported.default as unknown as DocumentNode
const validationResult = validateFragments(graphqlDocument)
expect(validationResult.valid).toBe(true)
}
for (const namedExports in imported) {
const graphqlDocument = imported[namedExports] as unknown as DocumentNode
if (!graphqlDocument) continue
expect(graphqlDocument.kind).toBe(Kind.DOCUMENT)
const validationResult = validateFragments(graphqlDocument)
expect(validationResult.missing).toEqual([])
expect(validationResult.valid).toBe(true)
}
})
it("should handle transitive fragment usage in operations", () => {
const doc = gql`
query TestQuery {
user {
...UserInfo
}
}
fragment UserInfo on User {
id
name
...ContactInfo
}
fragment ContactInfo on User {
email
phone
}
`
const validationResult = validateFragments(doc)
expect(validationResult.valid).toBe(true)
expect(validationResult.hasOperation).toBe(true)
expect(validationResult.missing).toEqual([])
expect(validationResult.unused).toEqual([])
})
it("should handle complex union type fragments", () => {
const doc = gql`
query TestQuery {
blocks {
__typename
...BlockA
...BlockB
}
}
fragment BlockA on TypeA {
id
title
}
fragment BlockB on TypeB {
id
content
}
`
const validationResult = validateFragments(doc)
expect(validationResult).toEqual({
valid: true,
hasOperation: true,
missing: [],
unused: [],
})
})
it.each(queryFiles)("validates query %s", async (file) => {
const imported = await import(file)
if (imported.default) {
const graphqlDocument = imported.default as unknown as DocumentNode
const validationResult = validateFragments(graphqlDocument)
expect(validationResult.valid).toBe(true)
}
for (const namedExport in imported) {
const graphqlDocument = imported[namedExport] as unknown as DocumentNode
if (!graphqlDocument) continue
expect(graphqlDocument.kind).toBe(Kind.DOCUMENT)
const validationResult = validateFragments(graphqlDocument)
expect(validationResult, `Validation failed for ${namedExport}`).toEqual({
valid: true,
hasOperation: true,
unused: [],
missing: [],
})
}
})
})
/**
* Validates GraphQL fragments in a document.
*
* For documents with operations:
* - All fragments must be used (no unused fragments allowed)
* - All referenced fragments must be defined (no missing fragments allowed)
*
* For documents with only fragments (no operations):
* - Fragments can be unused (they are definitions for external use)
* - All referenced fragments must still be defined (no missing fragments allowed)
*
* @param doc - The GraphQL document to validate
* @returns Validation result with valid flag, missing fragments, and unused fragments
*/
export function validateFragments(doc: DocumentNode) {
const defined = new Set<string>()
const used = new Set<string>()
const fragMap = new Map<string, any>()
for (const def of doc.definitions) {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
defined.add(def.name.value)
fragMap.set(def.name.value, def)
}
}
function walk(node: any, visitedFragments = new Set<string>()) {
if (!node) return
if (node.kind === Kind.FRAGMENT_SPREAD) {
const name = node.name.value
if (!used.has(name)) {
used.add(name)
// If we have the fragment definition, walk its selectionSet to mark transitive usages.
const fragDef = fragMap.get(name)
if (fragDef && !visitedFragments.has(name)) {
visitedFragments.add(name)
walk(fragDef.selectionSet, new Set(visitedFragments))
}
}
return
}
if (node.selectionSet) {
for (const sel of node.selectionSet.selections) {
walk(sel, visitedFragments)
}
}
// Also check in inline fragments and other selection types
if (node.selections) {
for (const sel of node.selections) {
walk(sel, visitedFragments)
}
}
}
const hasOperation = doc.definitions.some(
(d) => d.kind === Kind.OPERATION_DEFINITION
)
if (hasOperation) {
// Start from operations to determine which fragments are actually used.
for (const def of doc.definitions) {
if (def.kind === Kind.OPERATION_DEFINITION) {
walk(def)
}
}
} else {
// No operations: allow standalone fragments.
// Still validate fragment-to-fragment references (detect missing fragments).
for (const def of doc.definitions) {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
walk(def)
}
}
}
const missing = [...used].filter((name) => !defined.has(name))
const unused = [...defined].filter((name) => !used.has(name))
const valid = hasOperation
? missing.length === 0 && unused.length === 0
: missing.length === 0
return { valid, missing, unused, hasOperation }
}