Merged in chore/replace-graphql-tag/loader (pull request #3096)
Use turbopack for dev builds. Remove graphql-tag/loader, replaced by gql`` tag literals instead. Approved-by: Linus Flood
This commit is contained in:
332
packages/trpc/lib/graphql/validate.test.ts
Normal file
332
packages/trpc/lib/graphql/validate.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user