diff --git a/graphql/codegen/package.json b/graphql/codegen/package.json index e30ae3f94..e5aa1a8fc 100644 --- a/graphql/codegen/package.json +++ b/graphql/codegen/package.json @@ -47,9 +47,9 @@ "example:codegen:orm": "tsx src/cli/index.ts generate-orm --config examples/multi-target.config.ts", "example:codegen:sdk:schema": "node dist/cli/index.js generate --schema examples/example.schema.graphql --output examples/output/generated-sdk-schema", "example:codegen:orm:schema": "node dist/cli/index.js generate-orm --schema examples/example.schema.graphql --output examples/output/generated-orm-schema", - "example:sdk": "tsx examples/react-query-sdk.ts", - "example:orm": "tsx examples/orm-sdk.ts", - "example:sdk:typecheck": "tsc --noEmit --jsx react --esModuleInterop --skipLibCheck --moduleResolution node examples/react-hooks-test.tsx" + "example:sdk": "tsx examples/react-hooks-sdk-test.tsx", + "example:orm": "tsx examples/orm-sdk-test.ts", + "example:sdk:typecheck": "tsc --noEmit --jsx react --esModuleInterop --skipLibCheck --moduleResolution node examples/react-hooks-sdk-test.tsx" }, "dependencies": { "@babel/generator": "^7.28.5", diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap new file mode 100644 index 000000000..2581ad40f --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/client-generator.test.ts.snap @@ -0,0 +1,323 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`client-generator generateCreateClientFile generates createClient factory with models 1`] = ` +"/** + * ORM Client - createClient factory + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ +import { OrmClient } from "./client"; +import type { OrmClientConfig } from "./client"; +import { UserModel } from "./models/user"; +import { PostModel } from "./models/post"; +export type { OrmClientConfig, QueryResult, GraphQLError } from "./client"; +export { GraphQLRequestError } from "./client"; +export { QueryBuilder } from "./query-builder"; +export * from "./select-types"; +/** + * Create an ORM client instance + * + * @example + * \`\`\`typescript + * const db = createClient({ + * endpoint: 'https://api.example.com/graphql', + * headers: { Authorization: 'Bearer token' }, + * }); + * + * // Query users + * const users = await db.user.findMany({ + * select: { id: true, name: true }, + * first: 10, + * }).execute(); + * + * // Create a user + * const newUser = await db.user.create({ + * data: { name: 'John', email: 'john@example.com' }, + * select: { id: true }, + * }).execute(); + * \`\`\` + */ +export function createClient(config: OrmClientConfig) { + const client = new OrmClient(config); + return { + user: new UserModel(client), + post: new PostModel(client) + }; +}" +`; + +exports[`client-generator generateCreateClientFile includes custom query/mutation operations when available 1`] = ` +"/** + * ORM Client - createClient factory + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ +import { OrmClient } from "./client"; +import type { OrmClientConfig } from "./client"; +import { UserModel } from "./models/user"; +import { createQueryOperations } from "./query"; +import { createMutationOperations } from "./mutation"; +export type { OrmClientConfig, QueryResult, GraphQLError } from "./client"; +export { GraphQLRequestError } from "./client"; +export { QueryBuilder } from "./query-builder"; +export * from "./select-types"; +/** + * Create an ORM client instance + * + * @example + * \`\`\`typescript + * const db = createClient({ + * endpoint: 'https://api.example.com/graphql', + * headers: { Authorization: 'Bearer token' }, + * }); + * + * // Query users + * const users = await db.user.findMany({ + * select: { id: true, name: true }, + * first: 10, + * }).execute(); + * + * // Create a user + * const newUser = await db.user.create({ + * data: { name: 'John', email: 'john@example.com' }, + * select: { id: true }, + * }).execute(); + * \`\`\` + */ +export function createClient(config: OrmClientConfig) { + const client = new OrmClient(config); + return { + user: new UserModel(client), + query: createQueryOperations(client), + mutation: createMutationOperations(client) + }; +}" +`; + +exports[`client-generator generateOrmClientFile generates OrmClient class with execute method 1`] = ` +"/** + * ORM Client - Runtime GraphQL executor + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +export interface OrmClientConfig { + endpoint: string; + headers?: Record; +} + +export interface GraphQLError { + message: string; + locations?: { line: number; column: number }[]; + path?: (string | number)[]; + extensions?: Record; +} + +/** + * Error thrown when GraphQL request fails + */ +export class GraphQLRequestError extends Error { + constructor( + public readonly errors: GraphQLError[], + public readonly data: unknown = null + ) { + const messages = errors.map(e => e.message).join('; '); + super(\`GraphQL Error: \${messages}\`); + this.name = 'GraphQLRequestError'; + } +} + +/** + * Discriminated union for query results + * Use .ok to check success, or use .unwrap() to get data or throw + */ +export type QueryResult = + | { ok: true; data: T; errors: undefined } + | { ok: false; data: null; errors: GraphQLError[] }; + +/** + * Legacy QueryResult type for backwards compatibility + * @deprecated Use QueryResult discriminated union instead + */ +export interface LegacyQueryResult { + data: T | null; + errors?: GraphQLError[]; +} + +export class OrmClient { + private endpoint: string; + private headers: Record; + + constructor(config: OrmClientConfig) { + this.endpoint = config.endpoint; + this.headers = config.headers ?? {}; + } + + async execute( + document: string, + variables?: Record + ): Promise> { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...this.headers, + }, + body: JSON.stringify({ + query: document, + variables: variables ?? {}, + }), + }); + + if (!response.ok) { + return { + ok: false, + data: null, + errors: [{ message: \`HTTP \${response.status}: \${response.statusText}\` }], + }; + } + + const json = (await response.json()) as { + data?: T; + errors?: GraphQLError[]; + }; + + // Return discriminated union based on presence of errors + if (json.errors && json.errors.length > 0) { + return { + ok: false, + data: null, + errors: json.errors, + }; + } + + return { + ok: true, + data: json.data as T, + errors: undefined, + }; + } + + setHeaders(headers: Record): void { + this.headers = { ...this.headers, ...headers }; + } + + getEndpoint(): string { + return this.endpoint; + } +} +" +`; + +exports[`client-generator generateSelectTypesFile generates select type utilities 1`] = ` +"/** + * Type utilities for select inference + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +export interface ConnectionResult { + nodes: T[]; + totalCount: number; + pageInfo: PageInfo; +} + +export interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; +} + +export interface FindManyArgs { + select?: TSelect; + where?: TWhere; + orderBy?: TOrderBy[]; + first?: number; + last?: number; + after?: string; + before?: string; + offset?: number; +} + +export interface FindFirstArgs { + select?: TSelect; + where?: TWhere; +} + +export interface CreateArgs { + data: TData; + select?: TSelect; +} + +export interface UpdateArgs { + where: TWhere; + data: TData; + select?: TSelect; +} + +export interface DeleteArgs { + where: TWhere; +} + +/** + * Recursively validates select objects, rejecting unknown keys. + * + * This type ensures that users can only select fields that actually exist + * in the GraphQL schema. It returns \`never\` if any excess keys are found + * at any nesting level, causing a TypeScript compile error. + * + * Why this is needed: + * TypeScript's excess property checking has a quirk where it only catches + * invalid fields when they are the ONLY fields. When mixed with valid fields + * (e.g., \`{ id: true, invalidField: true }\`), the structural typing allows + * the excess property through. This type explicitly checks for and rejects + * such cases. + * + * @example + * // This will cause a type error because 'invalid' doesn't exist: + * type Result = DeepExact<{ id: true, invalid: true }, { id?: boolean }>; + * // Result = never (causes assignment error) + * + * @example + * // This works because all fields are valid: + * type Result = DeepExact<{ id: true }, { id?: boolean; name?: boolean }>; + * // Result = { id: true } + */ +export type DeepExact = T extends Shape + ? Exclude extends never + ? { + [K in keyof T]: K extends keyof Shape + ? T[K] extends { select: infer NS } + ? Shape[K] extends { select?: infer ShapeNS } + ? { select: DeepExact> } + : T[K] + : T[K] + : never; + } + : never + : never; + +/** + * Infer result type from select configuration + */ +export type InferSelectResult = TSelect extends undefined + ? TEntity + : { + [K in keyof TSelect as TSelect[K] extends false | undefined ? never : K]: TSelect[K] extends true + ? K extends keyof TEntity + ? TEntity[K] + : never + : TSelect[K] extends { select: infer NestedSelect } + ? K extends keyof TEntity + ? NonNullable extends ConnectionResult + ? ConnectionResult> + : InferSelectResult, NestedSelect> | (null extends TEntity[K] ? null : never) + : never + : K extends keyof TEntity + ? TEntity[K] + : never; + }; +" +`; diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap new file mode 100644 index 000000000..db345a4e2 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/model-generator.test.ts.snap @@ -0,0 +1,316 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`model-generator generates model with all CRUD methods 1`] = ` +"/** + * User model for ORM client + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ +import { OrmClient } from "../client"; +import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildCreateDocument, buildUpdateDocument, buildDeleteDocument } from "../query-builder"; +import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, DeepExact } from "../select-types"; +import type { User, UserWithRelations, UserSelect, UserFilter, UsersOrderBy, CreateUserInput, UpdateUserInput, UserPatch } from "../input-types"; +export class UserModel { + constructor(private client: OrmClient) {} + findMany(args?: FindManyArgs, UserFilter, UsersOrderBy>): QueryBuilder<{ + users: ConnectionResult>; + }> { + const { + document, + variables + } = buildFindManyDocument("User", "users", args?.select, { + where: args?.where, + orderBy: args?.orderBy as string[] | undefined, + first: args?.first, + last: args?.last, + after: args?.after, + before: args?.before, + offset: args?.offset + }, "UserFilter", "UsersOrderBy"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "User", + fieldName: "users", + document, + variables + }); + } + findFirst(args?: FindFirstArgs, UserFilter>): QueryBuilder<{ + users: { + nodes: InferSelectResult[]; + }; + }> { + const { + document, + variables + } = buildFindFirstDocument("User", "users", args?.select, { + where: args?.where + }, "UserFilter"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "User", + fieldName: "users", + document, + variables + }); + } + create(args: CreateArgs, CreateUserInput["user"]>): QueryBuilder<{ + createUser: { + user: InferSelectResult; + }; + }> { + const { + document, + variables + } = buildCreateDocument("User", "createUser", "user", args.select, args.data, "CreateUserInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "User", + fieldName: "createUser", + document, + variables + }); + } + update(args: UpdateArgs, { + id: string; + }, UserPatch>): QueryBuilder<{ + updateUser: { + user: InferSelectResult; + }; + }> { + const { + document, + variables + } = buildUpdateDocument("User", "updateUser", "user", args.select, args.where, args.data, "UpdateUserInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "User", + fieldName: "updateUser", + document, + variables + }); + } + delete(args: DeleteArgs<{ + id: string; + }>): QueryBuilder<{ + deleteUser: { + user: { + id: string; + }; + }; + }> { + const { + document, + variables + } = buildDeleteDocument("User", "deleteUser", "user", args.where, "DeleteUserInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "User", + fieldName: "deleteUser", + document, + variables + }); + } +}" +`; + +exports[`model-generator generates model without update/delete when not available 1`] = ` +"/** + * AuditLog model for ORM client + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ +import { OrmClient } from "../client"; +import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildCreateDocument, buildUpdateDocument, buildDeleteDocument } from "../query-builder"; +import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, DeepExact } from "../select-types"; +import type { AuditLog, AuditLogWithRelations, AuditLogSelect, AuditLogFilter, AuditLogsOrderBy, CreateAuditLogInput, UpdateAuditLogInput, AuditLogPatch } from "../input-types"; +export class AuditLogModel { + constructor(private client: OrmClient) {} + findMany(args?: FindManyArgs, AuditLogFilter, AuditLogsOrderBy>): QueryBuilder<{ + auditLogs: ConnectionResult>; + }> { + const { + document, + variables + } = buildFindManyDocument("AuditLog", "auditLogs", args?.select, { + where: args?.where, + orderBy: args?.orderBy as string[] | undefined, + first: args?.first, + last: args?.last, + after: args?.after, + before: args?.before, + offset: args?.offset + }, "AuditLogFilter", "AuditLogsOrderBy"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "AuditLog", + fieldName: "auditLogs", + document, + variables + }); + } + findFirst(args?: FindFirstArgs, AuditLogFilter>): QueryBuilder<{ + auditLogs: { + nodes: InferSelectResult[]; + }; + }> { + const { + document, + variables + } = buildFindFirstDocument("AuditLog", "auditLogs", args?.select, { + where: args?.where + }, "AuditLogFilter"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "AuditLog", + fieldName: "auditLogs", + document, + variables + }); + } + create(args: CreateArgs, CreateAuditLogInput["auditLog"]>): QueryBuilder<{ + createAuditLog: { + auditLog: InferSelectResult; + }; + }> { + const { + document, + variables + } = buildCreateDocument("AuditLog", "createAuditLog", "auditLog", args.select, args.data, "CreateAuditLogInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "AuditLog", + fieldName: "createAuditLog", + document, + variables + }); + } +}" +`; + +exports[`model-generator handles custom query/mutation names 1`] = ` +"/** + * Organization model for ORM client + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ +import { OrmClient } from "../client"; +import { QueryBuilder, buildFindManyDocument, buildFindFirstDocument, buildCreateDocument, buildUpdateDocument, buildDeleteDocument } from "../query-builder"; +import type { ConnectionResult, FindManyArgs, FindFirstArgs, CreateArgs, UpdateArgs, DeleteArgs, InferSelectResult, DeepExact } from "../select-types"; +import type { Organization, OrganizationWithRelations, OrganizationSelect, OrganizationFilter, OrganizationsOrderBy, CreateOrganizationInput, UpdateOrganizationInput, OrganizationPatch } from "../input-types"; +export class OrganizationModel { + constructor(private client: OrmClient) {} + findMany(args?: FindManyArgs, OrganizationFilter, OrganizationsOrderBy>): QueryBuilder<{ + allOrganizations: ConnectionResult>; + }> { + const { + document, + variables + } = buildFindManyDocument("Organization", "allOrganizations", args?.select, { + where: args?.where, + orderBy: args?.orderBy as string[] | undefined, + first: args?.first, + last: args?.last, + after: args?.after, + before: args?.before, + offset: args?.offset + }, "OrganizationFilter", "OrganizationsOrderBy"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "Organization", + fieldName: "allOrganizations", + document, + variables + }); + } + findFirst(args?: FindFirstArgs, OrganizationFilter>): QueryBuilder<{ + allOrganizations: { + nodes: InferSelectResult[]; + }; + }> { + const { + document, + variables + } = buildFindFirstDocument("Organization", "allOrganizations", args?.select, { + where: args?.where + }, "OrganizationFilter"); + return new QueryBuilder({ + client: this.client, + operation: "query", + operationName: "Organization", + fieldName: "allOrganizations", + document, + variables + }); + } + create(args: CreateArgs, CreateOrganizationInput["organization"]>): QueryBuilder<{ + registerOrganization: { + organization: InferSelectResult; + }; + }> { + const { + document, + variables + } = buildCreateDocument("Organization", "registerOrganization", "organization", args.select, args.data, "CreateOrganizationInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "Organization", + fieldName: "registerOrganization", + document, + variables + }); + } + update(args: UpdateArgs, { + id: string; + }, OrganizationPatch>): QueryBuilder<{ + modifyOrganization: { + organization: InferSelectResult; + }; + }> { + const { + document, + variables + } = buildUpdateDocument("Organization", "modifyOrganization", "organization", args.select, args.where, args.data, "UpdateOrganizationInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "Organization", + fieldName: "modifyOrganization", + document, + variables + }); + } + delete(args: DeleteArgs<{ + id: string; + }>): QueryBuilder<{ + removeOrganization: { + organization: { + id: string; + }; + }; + }> { + const { + document, + variables + } = buildDeleteDocument("Organization", "removeOrganization", "organization", args.where, "DeleteOrganizationInput"); + return new QueryBuilder({ + client: this.client, + operation: "mutation", + operationName: "Organization", + fieldName: "removeOrganization", + document, + variables + }); + } +}" +`; diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap new file mode 100644 index 000000000..f3111ec74 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/schema-types-generator.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`schema-types-generator generates enum types as string unions 1`] = ` +"/** + * GraphQL schema types for custom operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +export type Status = "ACTIVE" | "INACTIVE" | "PENDING"; +export type Priority = "LOW" | "MEDIUM" | "HIGH";" +`; + +exports[`schema-types-generator generates input object types as interfaces 1`] = ` +"/** + * GraphQL schema types for custom operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +export interface CreateUserInput { + email: string; + name?: string; + age?: number; +} +export interface UpdateUserInput { + id: string; + name?: string; +}" +`; + +exports[`schema-types-generator generates payload types from mutation return types 1`] = ` +"/** + * GraphQL schema types for custom operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +import type { User, BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +export interface LoginPayload { + token: string; + refreshToken?: string | null; + user?: User | null; +}" +`; + +exports[`schema-types-generator generates union types 1`] = ` +"/** + * GraphQL schema types for custom operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +export type SearchResult = User | Post | Comment;" +`; + +exports[`schema-types-generator skips table types and standard scalars 1`] = ` +"/** + * GraphQL schema types for custom operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */ + +import type { BigFloatFilter, BigIntFilter, BitStringFilter, BooleanFilter, DateFilter, DatetimeFilter, FloatFilter, FullTextFilter, IntFilter, IntListFilter, InternetAddressFilter, JSONFilter, StringFilter, StringListFilter, UUIDFilter, UUIDListFilter } from "./types"; +export type CustomEnum = "VALUE_A" | "VALUE_B";" +`; diff --git a/graphql/codegen/src/__tests__/codegen/client-generator.test.ts b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts new file mode 100644 index 000000000..f5b03828e --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/client-generator.test.ts @@ -0,0 +1,130 @@ +/** + * Snapshot tests for ORM client-generator.ts + * + * Tests the generated ORM client files: client.ts, query-builder.ts, select-types.ts, index.ts + */ +import { + generateOrmClientFile, + generateQueryBuilderFile, + generateSelectTypesFile, + generateCreateClientFile, +} from '../../cli/codegen/orm/client-generator'; +import type { CleanTable, CleanFieldType, CleanRelations } from '../../types/schema'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const fieldTypes = { + uuid: { gqlType: 'UUID', isArray: false } as CleanFieldType, + string: { gqlType: 'String', isArray: false } as CleanFieldType, +}; + +const emptyRelations: CleanRelations = { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [], +}; + +function createTable(partial: Partial & { name: string }): CleanTable { + return { + name: partial.name, + fields: partial.fields ?? [], + relations: partial.relations ?? emptyRelations, + query: partial.query, + inflection: partial.inflection, + constraints: partial.constraints, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('client-generator', () => { + describe('generateOrmClientFile', () => { + it('generates OrmClient class with execute method', () => { + const result = generateOrmClientFile(); + + expect(result.fileName).toBe('client.ts'); + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('class OrmClient'); + expect(result.content).toContain('execute'); + expect(result.content).toContain('QueryResult'); + expect(result.content).toContain('GraphQLRequestError'); + }); + }); + + describe('generateQueryBuilderFile', () => { + it('generates QueryBuilder with gql-ast document builders', () => { + const result = generateQueryBuilderFile(); + + expect(result.fileName).toBe('query-builder.ts'); + expect(result.content).toContain('class QueryBuilder'); + expect(result.content).toContain('buildFindManyDocument'); + expect(result.content).toContain('buildFindFirstDocument'); + expect(result.content).toContain('buildCreateDocument'); + expect(result.content).toContain('buildUpdateDocument'); + expect(result.content).toContain('buildDeleteDocument'); + expect(result.content).toContain("import * as t from 'gql-ast'"); + }); + }); + + describe('generateSelectTypesFile', () => { + it('generates select type utilities', () => { + const result = generateSelectTypesFile(); + + expect(result.fileName).toBe('select-types.ts'); + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('ConnectionResult'); + expect(result.content).toContain('PageInfo'); + expect(result.content).toContain('FindManyArgs'); + expect(result.content).toContain('DeepExact'); + expect(result.content).toContain('InferSelectResult'); + }); + }); + + describe('generateCreateClientFile', () => { + it('generates createClient factory with models', () => { + const tables = [ + createTable({ + name: 'User', + fields: [{ name: 'id', type: fieldTypes.uuid }], + query: { all: 'users', one: 'user', create: 'createUser', update: 'updateUser', delete: 'deleteUser' }, + }), + createTable({ + name: 'Post', + fields: [{ name: 'id', type: fieldTypes.uuid }], + query: { all: 'posts', one: 'post', create: 'createPost', update: 'updatePost', delete: 'deletePost' }, + }), + ]; + + const result = generateCreateClientFile(tables, false, false); + + expect(result.fileName).toBe('index.ts'); + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('createClient'); + expect(result.content).toContain('UserModel'); + expect(result.content).toContain('PostModel'); + }); + + it('includes custom query/mutation operations when available', () => { + const tables = [ + createTable({ + name: 'User', + fields: [{ name: 'id', type: fieldTypes.uuid }], + query: { all: 'users', one: 'user', create: 'createUser', update: 'updateUser', delete: 'deleteUser' }, + }), + ]; + + const result = generateCreateClientFile(tables, true, true); + + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('createQueryOperations'); + expect(result.content).toContain('createMutationOperations'); + expect(result.content).toContain("query: createQueryOperations(client)"); + expect(result.content).toContain("mutation: createMutationOperations(client)"); + }); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/model-generator.test.ts b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts new file mode 100644 index 000000000..75d7e5991 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/model-generator.test.ts @@ -0,0 +1,155 @@ +/** + * Snapshot tests for ORM model-generator.ts + * + * Tests the generated model classes with findMany, findFirst, create, update, delete methods. + */ +import { generateModelFile } from '../../cli/codegen/orm/model-generator'; +import type { CleanTable, CleanFieldType, CleanRelations } from '../../types/schema'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +const fieldTypes = { + uuid: { gqlType: 'UUID', isArray: false } as CleanFieldType, + string: { gqlType: 'String', isArray: false } as CleanFieldType, + int: { gqlType: 'Int', isArray: false } as CleanFieldType, + boolean: { gqlType: 'Boolean', isArray: false } as CleanFieldType, + datetime: { gqlType: 'Datetime', isArray: false } as CleanFieldType, +}; + +const emptyRelations: CleanRelations = { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [], +}; + +function createTable(partial: Partial & { name: string }): CleanTable { + return { + name: partial.name, + fields: partial.fields ?? [], + relations: partial.relations ?? emptyRelations, + query: partial.query, + inflection: partial.inflection, + constraints: partial.constraints, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('model-generator', () => { + it('generates model with all CRUD methods', () => { + const table = createTable({ + name: 'User', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'email', type: fieldTypes.string }, + { name: 'name', type: fieldTypes.string }, + { name: 'isActive', type: fieldTypes.boolean }, + { name: 'createdAt', type: fieldTypes.datetime }, + ], + query: { + all: 'users', + one: 'user', + create: 'createUser', + update: 'updateUser', + delete: 'deleteUser', + }, + }); + + const result = generateModelFile(table, false); + + expect(result.fileName).toBe('user.ts'); + expect(result.modelName).toBe('UserModel'); + expect(result.content).toMatchSnapshot(); + }); + + it('generates model without update/delete when not available', () => { + const table = createTable({ + name: 'AuditLog', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'action', type: fieldTypes.string }, + { name: 'timestamp', type: fieldTypes.datetime }, + ], + query: { + all: 'auditLogs', + one: 'auditLog', + create: 'createAuditLog', + update: undefined, + delete: undefined, + }, + }); + + const result = generateModelFile(table, false); + + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('findMany'); + expect(result.content).toContain('findFirst'); + expect(result.content).toContain('create'); + expect(result.content).not.toContain('update('); + expect(result.content).not.toContain('delete('); + }); + + it('handles custom query/mutation names', () => { + const table = createTable({ + name: 'Organization', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + ], + query: { + all: 'allOrganizations', + one: 'organizationById', + create: 'registerOrganization', + update: 'modifyOrganization', + delete: 'removeOrganization', + }, + }); + + const result = generateModelFile(table, false); + + expect(result.content).toMatchSnapshot(); + expect(result.content).toContain('"allOrganizations"'); + expect(result.content).toContain('"registerOrganization"'); + expect(result.content).toContain('"modifyOrganization"'); + expect(result.content).toContain('"removeOrganization"'); + }); + + it('generates correct type imports', () => { + const table = createTable({ + name: 'Product', + fields: [ + { name: 'id', type: fieldTypes.uuid }, + { name: 'name', type: fieldTypes.string }, + { name: 'price', type: fieldTypes.int }, + ], + query: { + all: 'products', + one: 'product', + create: 'createProduct', + update: 'updateProduct', + delete: 'deleteProduct', + }, + }); + + const result = generateModelFile(table, false); + + // Check imports + expect(result.content).toContain('from "../client"'); + expect(result.content).toContain('from "../query-builder"'); + expect(result.content).toContain('from "../select-types"'); + expect(result.content).toContain('from "../input-types"'); + + // Check type references + expect(result.content).toContain('ProductSelect'); + expect(result.content).toContain('ProductFilter'); + expect(result.content).toContain('ProductsOrderBy'); + expect(result.content).toContain('CreateProductInput'); + expect(result.content).toContain('UpdateProductInput'); + expect(result.content).toContain('ProductPatch'); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/query-builder.test.ts b/graphql/codegen/src/__tests__/codegen/query-builder.test.ts new file mode 100644 index 000000000..b91c420fc --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/query-builder.test.ts @@ -0,0 +1,225 @@ +/** + * Unit tests for query-builder.ts document builders + * + * Tests the core GraphQL document building functions using gql-ast. + * Functions are re-implemented here to avoid ./client import issues. + */ +import * as t from 'gql-ast'; +import { parseType, print } from 'graphql'; +import type { ArgumentNode, FieldNode, VariableDefinitionNode } from 'graphql'; + +// ============================================================================ +// Core functions from query-builder.ts (re-implemented for testing) +// ============================================================================ + +function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { + return [ + t.field({ + name: 'nodes', + selectionSet: t.selectionSet({ selections: nodeSelections }), + }), + t.field({ name: 'totalCount' }), + t.field({ + name: 'pageInfo', + selectionSet: t.selectionSet({ + selections: [ + t.field({ name: 'hasNextPage' }), + t.field({ name: 'hasPreviousPage' }), + t.field({ name: 'startCursor' }), + t.field({ name: 'endCursor' }), + ], + }), + }), + ]; +} + +function addVariable( + spec: { varName: string; argName?: string; typeName: string; value: unknown }, + definitions: VariableDefinitionNode[], + args: ArgumentNode[], + variables: Record +): void { + if (spec.value === undefined) return; + definitions.push( + t.variableDefinition({ + variable: t.variable({ name: spec.varName }), + type: parseType(spec.typeName), + }) + ); + args.push( + t.argument({ + name: spec.argName ?? spec.varName, + value: t.variable({ name: spec.varName }), + }) + ); + variables[spec.varName] = spec.value; +} + +function buildSelections(select: Record | undefined): FieldNode[] { + if (!select) return []; + const fields: FieldNode[] = []; + for (const [key, value] of Object.entries(select)) { + if (value === true) { + fields.push(t.field({ name: key })); + } else if (value && typeof value === 'object' && 'select' in value) { + const nested = value as { select: Record }; + fields.push( + t.field({ + name: key, + selectionSet: t.selectionSet({ selections: buildSelections(nested.select) }), + }) + ); + } + } + return fields; +} + +function buildFindManyDocument( + operationName: string, + queryField: string, + select: TSelect, + args: { where?: TWhere; first?: number; orderBy?: string[] }, + filterTypeName: string, + orderByTypeName: string +): { document: string; variables: Record } { + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; + const variableDefinitions: VariableDefinitionNode[] = []; + const queryArgs: ArgumentNode[] = []; + const variables: Record = {}; + + addVariable({ varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'orderBy', typeName: `[${orderByTypeName}!]`, value: args.orderBy?.length ? args.orderBy : undefined }, variableDefinitions, queryArgs, variables); + addVariable({ varName: 'first', typeName: 'Int', value: args.first }, variableDefinitions, queryArgs, variables); + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'query', + name: operationName + 'Query', + variableDefinitions: variableDefinitions.length ? variableDefinitions : undefined, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: queryField, + args: queryArgs.length ? queryArgs : undefined, + selectionSet: t.selectionSet({ selections: buildConnectionSelections(selections) }), + }), + ], + }), + }), + ], + }); + return { document: print(document), variables }; +} + +function buildMutationDocument( + operationName: string, + mutationField: string, + entityField: string, + selections: FieldNode[], + inputTypeName: string +): string { + return print( + t.document({ + definitions: [ + t.operationDefinition({ + operation: 'mutation', + name: operationName + 'Mutation', + variableDefinitions: [ + t.variableDefinition({ + variable: t.variable({ name: 'input' }), + type: parseType(inputTypeName + '!'), + }), + ], + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: mutationField, + args: [t.argument({ name: 'input', value: t.variable({ name: 'input' }) })], + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + }), + ], + }), + }), + ], + }) + ); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('query-builder', () => { + describe('buildSelections', () => { + it('builds flat and nested selections', () => { + const result = buildSelections({ + id: true, + name: true, + ignored: false, + profile: { select: { bio: true } }, + }); + + expect(result).toHaveLength(3); + expect(result[0].name.value).toBe('id'); + expect(result[1].name.value).toBe('name'); + expect(result[2].name.value).toBe('profile'); + expect(result[2].selectionSet?.selections).toHaveLength(1); + }); + }); + + describe('buildFindManyDocument', () => { + it('builds query with filter, pagination, and orderBy', () => { + const { document, variables } = buildFindManyDocument( + 'Users', + 'users', + { id: true, name: true }, + { where: { status: { equalTo: 'active' } }, first: 10, orderBy: ['NAME_ASC'] }, + 'UserFilter', + 'UsersOrderBy' + ); + + expect(document).toContain('query UsersQuery'); + expect(document).toContain('$where: UserFilter'); + expect(document).toContain('$first: Int'); + expect(document).toContain('$orderBy: [UsersOrderBy!]'); + expect(document).toContain('filter: $where'); + expect(document).toContain('nodes {'); + expect(document).toContain('totalCount'); + expect(document).toContain('pageInfo'); + expect(variables).toEqual({ + where: { status: { equalTo: 'active' } }, + first: 10, + orderBy: ['NAME_ASC'], + }); + }); + }); + + describe('buildMutationDocument', () => { + it('builds create/update/delete mutations with input variable', () => { + const document = buildMutationDocument( + 'CreateUser', + 'createUser', + 'user', + [t.field({ name: 'id' }), t.field({ name: 'name' })], + 'CreateUserInput' + ); + + expect(document).toContain('mutation CreateUserMutation'); + expect(document).toContain('$input: CreateUserInput!'); + expect(document).toContain('createUser(input: $input)'); + expect(document).toContain('user {'); + expect(document).toContain('id'); + expect(document).toContain('name'); + }); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/scalars.test.ts b/graphql/codegen/src/__tests__/codegen/scalars.test.ts new file mode 100644 index 000000000..65c61adf5 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/scalars.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for scalar mappings + */ +import { + SCALAR_TS_MAP, + SCALAR_FILTER_MAP, + SCALAR_NAMES, + BASE_FILTER_TYPE_NAMES, + scalarToTsType, + scalarToFilterType, +} from '../../cli/codegen/scalars'; + +describe('scalars', () => { + describe('SCALAR_TS_MAP', () => { + it('maps standard GraphQL scalars', () => { + expect(SCALAR_TS_MAP.String).toBe('string'); + expect(SCALAR_TS_MAP.Int).toBe('number'); + expect(SCALAR_TS_MAP.Float).toBe('number'); + expect(SCALAR_TS_MAP.Boolean).toBe('boolean'); + expect(SCALAR_TS_MAP.ID).toBe('string'); + }); + + it('maps PostGraphile scalars', () => { + expect(SCALAR_TS_MAP.UUID).toBe('string'); + expect(SCALAR_TS_MAP.Datetime).toBe('string'); + expect(SCALAR_TS_MAP.JSON).toBe('unknown'); + expect(SCALAR_TS_MAP.BigInt).toBe('string'); + }); + + it('maps PostgreSQL-specific types', () => { + expect(SCALAR_TS_MAP.Inet).toBe('string'); + expect(SCALAR_TS_MAP.TsVector).toBe('string'); + }); + }); + + describe('SCALAR_FILTER_MAP', () => { + it('maps scalars to filter types', () => { + expect(SCALAR_FILTER_MAP.String).toBe('StringFilter'); + expect(SCALAR_FILTER_MAP.Int).toBe('IntFilter'); + expect(SCALAR_FILTER_MAP.UUID).toBe('UUIDFilter'); + expect(SCALAR_FILTER_MAP.Datetime).toBe('DatetimeFilter'); + }); + + it('maps ID to UUIDFilter', () => { + expect(SCALAR_FILTER_MAP.ID).toBe('UUIDFilter'); + }); + }); + + describe('SCALAR_NAMES', () => { + it('contains all scalar names from SCALAR_TS_MAP', () => { + expect(SCALAR_NAMES.has('String')).toBe(true); + expect(SCALAR_NAMES.has('UUID')).toBe(true); + expect(SCALAR_NAMES.has('Upload')).toBe(true); + expect(SCALAR_NAMES.has('NotAScalar')).toBe(false); + }); + }); + + describe('BASE_FILTER_TYPE_NAMES', () => { + it('includes scalar filter types', () => { + expect(BASE_FILTER_TYPE_NAMES.has('StringFilter')).toBe(true); + expect(BASE_FILTER_TYPE_NAMES.has('IntFilter')).toBe(true); + expect(BASE_FILTER_TYPE_NAMES.has('UUIDFilter')).toBe(true); + }); + + it('includes list filter types', () => { + expect(BASE_FILTER_TYPE_NAMES.has('StringListFilter')).toBe(true); + expect(BASE_FILTER_TYPE_NAMES.has('IntListFilter')).toBe(true); + expect(BASE_FILTER_TYPE_NAMES.has('UUIDListFilter')).toBe(true); + }); + }); + + describe('scalarToTsType', () => { + it('returns mapped TypeScript type for known scalars', () => { + expect(scalarToTsType('String')).toBe('string'); + expect(scalarToTsType('UUID')).toBe('string'); + expect(scalarToTsType('JSON')).toBe('unknown'); + }); + + it('returns scalar name for unknown scalars by default', () => { + expect(scalarToTsType('CustomScalar')).toBe('CustomScalar'); + }); + + it('returns unknown for unknown scalars when option set', () => { + expect(scalarToTsType('CustomScalar', { unknownScalar: 'unknown' })).toBe('unknown'); + }); + + it('uses overrides when provided', () => { + expect(scalarToTsType('JSON', { overrides: { JSON: 'JsonValue' } })).toBe('JsonValue'); + }); + }); + + describe('scalarToFilterType', () => { + it('returns filter type for known scalars', () => { + expect(scalarToFilterType('String')).toBe('StringFilter'); + expect(scalarToFilterType('Int')).toBe('IntFilter'); + expect(scalarToFilterType('UUID')).toBe('UUIDFilter'); + }); + + it('returns null for unknown scalars', () => { + expect(scalarToFilterType('CustomScalar')).toBeNull(); + }); + + it('returns list filter for array types', () => { + expect(scalarToFilterType('String', true)).toBe('StringListFilter'); + expect(scalarToFilterType('Int', true)).toBe('IntListFilter'); + expect(scalarToFilterType('UUID', true)).toBe('UUIDListFilter'); + }); + + it('returns null for array of non-list-filterable scalar', () => { + expect(scalarToFilterType('Datetime', true)).toBeNull(); + }); + + it('treats ID as UUID for filtering', () => { + expect(scalarToFilterType('ID')).toBe('UUIDFilter'); + expect(scalarToFilterType('ID', true)).toBe('UUIDListFilter'); + }); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts b/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts new file mode 100644 index 000000000..b4b041a33 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/schema-types-generator.test.ts @@ -0,0 +1,125 @@ +/** + * Snapshot tests for schema-types-generator + */ +import { generateSchemaTypesFile } from '../../cli/codegen/schema-types-generator'; +import type { TypeRegistry, ResolvedType } from '../../types/schema'; + +function createTypeRegistry(types: Array<[string, ResolvedType]>): TypeRegistry { + return new Map(types); +} + +describe('schema-types-generator', () => { + it('generates enum types as string unions', () => { + const registry = createTypeRegistry([ + ['Status', { kind: 'ENUM', name: 'Status', enumValues: ['ACTIVE', 'INACTIVE', 'PENDING'] }], + ['Priority', { kind: 'ENUM', name: 'Priority', enumValues: ['LOW', 'MEDIUM', 'HIGH'] }], + ]); + + const result = generateSchemaTypesFile({ + typeRegistry: registry, + tableTypeNames: new Set(), + }); + + expect(result.content).toMatchSnapshot(); + expect(result.generatedEnums).toEqual(['Priority', 'Status']); + }); + + it('generates input object types as interfaces', () => { + const registry = createTypeRegistry([ + [ + 'CreateUserInput', + { + kind: 'INPUT_OBJECT', + name: 'CreateUserInput', + inputFields: [ + { name: 'email', type: { kind: 'NON_NULL', name: null, ofType: { kind: 'SCALAR', name: 'String' } } }, + { name: 'name', type: { kind: 'SCALAR', name: 'String' } }, + { name: 'age', type: { kind: 'SCALAR', name: 'Int' } }, + ], + }, + ], + [ + 'UpdateUserInput', + { + kind: 'INPUT_OBJECT', + name: 'UpdateUserInput', + inputFields: [ + { name: 'id', type: { kind: 'NON_NULL', name: null, ofType: { kind: 'SCALAR', name: 'UUID' } } }, + { name: 'name', type: { kind: 'SCALAR', name: 'String' } }, + ], + }, + ], + ]); + + const result = generateSchemaTypesFile({ + typeRegistry: registry, + tableTypeNames: new Set(), + }); + + expect(result.content).toMatchSnapshot(); + }); + + it('generates union types', () => { + const registry = createTypeRegistry([ + ['SearchResult', { kind: 'UNION', name: 'SearchResult', possibleTypes: ['User', 'Post', 'Comment'] }], + ]); + + const result = generateSchemaTypesFile({ + typeRegistry: registry, + tableTypeNames: new Set(), + }); + + expect(result.content).toMatchSnapshot(); + }); + + it('generates payload types from mutation return types', () => { + const registry = createTypeRegistry([ + [ + 'Mutation', + { + kind: 'OBJECT', + name: 'Mutation', + fields: [ + { name: 'login', type: { kind: 'OBJECT', name: 'LoginPayload' } }, + ], + }, + ], + [ + 'LoginPayload', + { + kind: 'OBJECT', + name: 'LoginPayload', + fields: [ + { name: 'token', type: { kind: 'NON_NULL', name: null, ofType: { kind: 'SCALAR', name: 'String' } } }, + { name: 'refreshToken', type: { kind: 'SCALAR', name: 'String' } }, + { name: 'user', type: { kind: 'OBJECT', name: 'User' } }, + ], + }, + ], + ]); + + const result = generateSchemaTypesFile({ + typeRegistry: registry, + tableTypeNames: new Set(['User']), + }); + + expect(result.content).toMatchSnapshot(); + expect(result.referencedTableTypes).toContain('User'); + }); + + it('skips table types and standard scalars', () => { + const registry = createTypeRegistry([ + ['User', { kind: 'ENUM', name: 'User', enumValues: ['ADMIN'] }], + ['String', { kind: 'ENUM', name: 'String', enumValues: ['A'] }], + ['CustomEnum', { kind: 'ENUM', name: 'CustomEnum', enumValues: ['VALUE_A', 'VALUE_B'] }], + ]); + + const result = generateSchemaTypesFile({ + typeRegistry: registry, + tableTypeNames: new Set(['User']), + }); + + expect(result.content).toMatchSnapshot(); + expect(result.generatedEnums).toEqual(['CustomEnum']); + }); +}); diff --git a/graphql/codegen/src/__tests__/codegen/utils.test.ts b/graphql/codegen/src/__tests__/codegen/utils.test.ts new file mode 100644 index 000000000..5554bdbf6 --- /dev/null +++ b/graphql/codegen/src/__tests__/codegen/utils.test.ts @@ -0,0 +1,185 @@ +/** + * Tests for codegen utility functions + */ +import { + lcFirst, + ucFirst, + toCamelCase, + toPascalCase, + toScreamingSnake, + getTableNames, + getFilterTypeName, + getOrderByTypeName, + gqlTypeToTs, + getPrimaryKeyInfo, + getGeneratedFileHeader, +} from '../../cli/codegen/utils'; +import type { CleanTable, CleanRelations } from '../../types/schema'; + +const emptyRelations: CleanRelations = { + belongsTo: [], + hasOne: [], + hasMany: [], + manyToMany: [], +}; + +// Use any for test fixture overrides to avoid strict type requirements +function createTable(name: string, overrides: Record = {}): CleanTable { + return { + name, + fields: [], + relations: emptyRelations, + ...overrides, + } as CleanTable; +} + +describe('utils', () => { + describe('string manipulation', () => { + it('lcFirst lowercases first character', () => { + expect(lcFirst('Hello')).toBe('hello'); + expect(lcFirst('ABC')).toBe('aBC'); + expect(lcFirst('a')).toBe('a'); + }); + + it('ucFirst uppercases first character', () => { + expect(ucFirst('hello')).toBe('Hello'); + expect(ucFirst('abc')).toBe('Abc'); + expect(ucFirst('A')).toBe('A'); + }); + + it('toCamelCase converts various formats', () => { + expect(toCamelCase('hello-world')).toBe('helloWorld'); + expect(toCamelCase('hello_world')).toBe('helloWorld'); + expect(toCamelCase('HelloWorld')).toBe('helloWorld'); + }); + + it('toPascalCase converts various formats', () => { + expect(toPascalCase('hello-world')).toBe('HelloWorld'); + expect(toPascalCase('hello_world')).toBe('HelloWorld'); + expect(toPascalCase('helloWorld')).toBe('HelloWorld'); + }); + + it('toScreamingSnake converts to SCREAMING_SNAKE_CASE', () => { + expect(toScreamingSnake('helloWorld')).toBe('HELLO_WORLD'); + expect(toScreamingSnake('HelloWorld')).toBe('HELLO_WORLD'); + expect(toScreamingSnake('hello')).toBe('HELLO'); + }); + }); + + describe('getTableNames', () => { + it('derives names from table name', () => { + const result = getTableNames(createTable('User')); + expect(result.typeName).toBe('User'); + expect(result.singularName).toBe('user'); + expect(result.pluralName).toBe('users'); + expect(result.pluralTypeName).toBe('Users'); + }); + + it('uses inflection overrides when provided', () => { + const result = getTableNames( + createTable('Person', { + inflection: { tableFieldName: 'individual', allRows: 'people' }, + }) + ); + expect(result.singularName).toBe('individual'); + expect(result.pluralName).toBe('people'); + }); + + it('uses query.all for plural name', () => { + const result = getTableNames( + createTable('Child', { + query: { all: 'children', one: 'child', create: 'createChild', update: 'updateChild', delete: 'deleteChild' }, + }) + ); + expect(result.pluralName).toBe('children'); + }); + }); + + describe('type name generators', () => { + it('getFilterTypeName returns correct filter type', () => { + expect(getFilterTypeName(createTable('User'))).toBe('UserFilter'); + expect(getFilterTypeName(createTable('Post', { inflection: { filterType: 'PostCondition' } }))).toBe('PostCondition'); + }); + + it('getOrderByTypeName returns correct orderBy type', () => { + expect(getOrderByTypeName(createTable('User'))).toBe('UsersOrderBy'); + expect(getOrderByTypeName(createTable('Address'))).toBe('AddressesOrderBy'); + }); + }); + + describe('gqlTypeToTs', () => { + it('maps standard scalars', () => { + expect(gqlTypeToTs('String')).toBe('string'); + expect(gqlTypeToTs('Int')).toBe('number'); + expect(gqlTypeToTs('Float')).toBe('number'); + expect(gqlTypeToTs('Boolean')).toBe('boolean'); + expect(gqlTypeToTs('ID')).toBe('string'); + }); + + it('maps PostGraphile scalars', () => { + expect(gqlTypeToTs('UUID')).toBe('string'); + expect(gqlTypeToTs('Datetime')).toBe('string'); + expect(gqlTypeToTs('JSON')).toBe('unknown'); + expect(gqlTypeToTs('BigInt')).toBe('string'); + }); + + it('handles array types', () => { + expect(gqlTypeToTs('String', true)).toBe('string[]'); + expect(gqlTypeToTs('Int', true)).toBe('number[]'); + }); + + it('preserves custom type names', () => { + expect(gqlTypeToTs('CustomType')).toBe('CustomType'); + expect(gqlTypeToTs('MyEnum')).toBe('MyEnum'); + }); + }); + + describe('getPrimaryKeyInfo', () => { + it('extracts PK from constraints', () => { + const table = createTable('User', { + constraints: { + primaryKey: [{ name: 'users_pkey', fields: [{ name: 'id', type: { gqlType: 'UUID', isArray: false } }] }], + }, + }); + const result = getPrimaryKeyInfo(table); + expect(result).toEqual([{ name: 'id', gqlType: 'UUID', tsType: 'string' }]); + }); + + it('falls back to id field', () => { + const table = createTable('User', { + fields: [{ name: 'id', type: { gqlType: 'UUID', isArray: false } }], + }); + const result = getPrimaryKeyInfo(table); + expect(result).toEqual([{ name: 'id', gqlType: 'UUID', tsType: 'string' }]); + }); + + it('handles composite keys', () => { + const table = createTable('UserRole', { + constraints: { + primaryKey: [ + { + name: 'user_roles_pkey', + fields: [ + { name: 'userId', type: { gqlType: 'UUID', isArray: false } }, + { name: 'roleId', type: { gqlType: 'UUID', isArray: false } }, + ], + }, + ], + }, + }); + const result = getPrimaryKeyInfo(table); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('userId'); + expect(result[1].name).toBe('roleId'); + }); + }); + + describe('getGeneratedFileHeader', () => { + it('generates proper header comment', () => { + const header = getGeneratedFileHeader('Test description'); + expect(header).toContain('Test description'); + expect(header).toContain('@generated'); + expect(header).toContain('DO NOT EDIT'); + }); + }); +}); diff --git a/graphql/codegen/src/cli/codegen/orm/client-generator.ts b/graphql/codegen/src/cli/codegen/orm/client-generator.ts index 56b10a300..08e3bbf29 100644 --- a/graphql/codegen/src/cli/codegen/orm/client-generator.ts +++ b/graphql/codegen/src/cli/codegen/orm/client-generator.ts @@ -7,6 +7,8 @@ import type { CleanTable } from '../../../types/schema'; import * as t from '@babel/types'; import { generateCode, commentBlock } from '../babel-ast'; import { getTableNames, lcFirst, getGeneratedFileHeader } from '../utils'; +import * as fs from 'fs'; +import * as path from 'path'; export interface GeneratedClientFile { fileName: string; content: string; @@ -141,628 +143,55 @@ export class OrmClient { /** * Generate the query-builder.ts file (runtime query builder) + * + * Reads from the actual TypeScript file in the source directory, + * which enables proper type checking and testability. */ export function generateQueryBuilderFile(): GeneratedClientFile { - const content = `/** - * Query Builder - Builds and executes GraphQL operations - * @generated by @constructive-io/graphql-codegen - * DO NOT EDIT - changes will be overwritten - */ - -import * as t from 'gql-ast'; -import { parseType, print } from 'graphql'; -import type { - ArgumentNode, - FieldNode, - VariableDefinitionNode, - EnumValueNode, -} from 'graphql'; -import { OrmClient, QueryResult, GraphQLRequestError } from './client'; - -export interface QueryBuilderConfig { - client: OrmClient; - operation: 'query' | 'mutation'; - operationName: string; - fieldName: string; - document: string; - variables?: Record; -} - -export class QueryBuilder { - private config: QueryBuilderConfig; - - constructor(config: QueryBuilderConfig) { - this.config = config; + // Read the query-builder.ts source file + // Handle both development (src/) and production (dist/) scenarios + let sourceFilePath = path.join(__dirname, 'query-builder.ts'); + + // If running from dist/, look for the source in src/ instead + if (!fs.existsSync(sourceFilePath)) { + // Navigate from dist/cli/codegen/orm/ to src/cli/codegen/orm/ + sourceFilePath = path.resolve(__dirname, '../../../../src/cli/codegen/orm/query-builder.ts'); } - /** - * Execute the query and return a discriminated union result - * Use result.ok to check success, or .unwrap() to throw on error - */ - async execute(): Promise> { - return this.config.client.execute( - this.config.document, - this.config.variables + // If still not found, try relative to package root + if (!fs.existsSync(sourceFilePath)) { + // For installed packages, the file should be adjacent in the same dir + throw new Error( + `Could not find query-builder.ts source file. ` + + `Searched in: ${path.join(__dirname, 'query-builder.ts')} and ` + + `${path.resolve(__dirname, '../../../../src/cli/codegen/orm/query-builder.ts')}` ); } - /** - * Execute and unwrap the result, throwing GraphQLRequestError on failure - * @throws {GraphQLRequestError} If the query returns errors - */ - async unwrap(): Promise { - const result = await this.execute(); - if (!result.ok) { - throw new GraphQLRequestError(result.errors, result.data); - } - return result.data; - } - - /** - * Execute and unwrap, returning defaultValue on error instead of throwing - */ - async unwrapOr(defaultValue: D): Promise { - const result = await this.execute(); - if (!result.ok) { - return defaultValue; - } - return result.data; - } - - /** - * Execute and unwrap, calling onError callback on failure - */ - async unwrapOrElse(onError: (errors: import('./client').GraphQLError[]) => D): Promise { - const result = await this.execute(); - if (!result.ok) { - return onError(result.errors); - } - return result.data; - } - - toGraphQL(): string { - return this.config.document; - } - - getVariables(): Record | undefined { - return this.config.variables; - } -} - -// ============================================================================ -// Selection Builders -// ============================================================================ - -export function buildSelections( - select: Record | undefined -): FieldNode[] { - if (!select) { - return []; - } - - const fields: FieldNode[] = []; - - for (const [key, value] of Object.entries(select)) { - if (value === false || value === undefined) { - continue; - } - - if (value === true) { - fields.push(t.field({ name: key })); - continue; - } - - if (typeof value === 'object' && value !== null) { - const nested = value as { - select?: Record; - first?: number; - filter?: Record; - orderBy?: string[]; - connection?: boolean; - }; - - if (nested.select) { - const nestedSelections = buildSelections(nested.select); - const isConnection = - nested.connection === true || - nested.first !== undefined || - nested.filter !== undefined; - const args = buildArgs([ - buildOptionalArg('first', nested.first), - nested.filter - ? t.argument({ name: 'filter', value: buildValueAst(nested.filter) }) - : null, - buildEnumListArg('orderBy', nested.orderBy), - ]); - - if (isConnection) { - fields.push( - t.field({ - name: key, - args, - selectionSet: t.selectionSet({ - selections: buildConnectionSelections(nestedSelections), - }), - }) - ); - } else { - fields.push( - t.field({ - name: key, - args, - selectionSet: t.selectionSet({ selections: nestedSelections }), - }) - ); - } - } - } - } - - return fields; -} - -// ============================================================================ -// Document Builders -// ============================================================================ - -export function buildFindManyDocument( - operationName: string, - queryField: string, - select: TSelect, - args: { - where?: TWhere; - orderBy?: string[]; - first?: number; - last?: number; - after?: string; - before?: string; - offset?: number; - }, - filterTypeName: string, - orderByTypeName: string -): { document: string; variables: Record } { - const selections = select - ? buildSelections(select as Record) - : [t.field({ name: 'id' })]; - - const variableDefinitions: VariableDefinitionNode[] = []; - const queryArgs: ArgumentNode[] = []; - const variables: Record = {}; - - addVariable({ varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'orderBy', typeName: '[' + orderByTypeName + '!]', value: args.orderBy?.length ? args.orderBy : undefined }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'first', typeName: 'Int', value: args.first }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'last', typeName: 'Int', value: args.last }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'after', typeName: 'Cursor', value: args.after }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'before', typeName: 'Cursor', value: args.before }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'offset', typeName: 'Int', value: args.offset }, variableDefinitions, queryArgs, variables); - - const document = t.document({ - definitions: [ - t.operationDefinition({ - operation: 'query', - name: operationName + 'Query', - variableDefinitions: variableDefinitions.length ? variableDefinitions : undefined, - selectionSet: t.selectionSet({ - selections: [ - t.field({ - name: queryField, - args: queryArgs.length ? queryArgs : undefined, - selectionSet: t.selectionSet({ - selections: buildConnectionSelections(selections), - }), - }), - ], - }), - }), - ], - }); - - return { document: print(document), variables }; -} - -export function buildFindFirstDocument( - operationName: string, - queryField: string, - select: TSelect, - args: { where?: TWhere }, - filterTypeName: string -): { document: string; variables: Record } { - const selections = select - ? buildSelections(select as Record) - : [t.field({ name: 'id' })]; - - const variableDefinitions: VariableDefinitionNode[] = []; - const queryArgs: ArgumentNode[] = []; - const variables: Record = {}; - - // Always add first: 1 for findFirst - addVariable({ varName: 'first', typeName: 'Int', value: 1 }, variableDefinitions, queryArgs, variables); - addVariable({ varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, variableDefinitions, queryArgs, variables); - - const document = t.document({ - definitions: [ - t.operationDefinition({ - operation: 'query', - name: operationName + 'Query', - variableDefinitions, - selectionSet: t.selectionSet({ - selections: [ - t.field({ - name: queryField, - args: queryArgs, - selectionSet: t.selectionSet({ - selections: [ - t.field({ - name: 'nodes', - selectionSet: t.selectionSet({ selections }), - }), - ], - }), - }), - ], - }), - }), - ], - }); - - return { document: print(document), variables }; -} - -export function buildCreateDocument( - operationName: string, - mutationField: string, - entityField: string, - select: TSelect, - data: TData, - inputTypeName: string -): { document: string; variables: Record } { - const selections = select - ? buildSelections(select as Record) - : [t.field({ name: 'id' })]; - - return { - document: buildInputMutationDocument({ - operationName, - mutationField, - inputTypeName, - resultSelections: [ - t.field({ - name: entityField, - selectionSet: t.selectionSet({ selections }), - }), - ], - }), - variables: { - input: { - [entityField]: data, - }, - }, - }; -} - -export function buildUpdateDocument( - operationName: string, - mutationField: string, - entityField: string, - select: TSelect, - where: TWhere, - data: TData, - inputTypeName: string -): { document: string; variables: Record } { - const selections = select - ? buildSelections(select as Record) - : [t.field({ name: 'id' })]; - - return { - document: buildInputMutationDocument({ - operationName, - mutationField, - inputTypeName, - resultSelections: [ - t.field({ - name: entityField, - selectionSet: t.selectionSet({ selections }), - }), - ], - }), - variables: { - input: { - id: where.id, - patch: data, - }, - }, - }; -} - -export function buildDeleteDocument( - operationName: string, - mutationField: string, - entityField: string, - where: TWhere, - inputTypeName: string -): { document: string; variables: Record } { - return { - document: buildInputMutationDocument({ - operationName, - mutationField, - inputTypeName, - resultSelections: [ - t.field({ - name: entityField, - selectionSet: t.selectionSet({ - selections: [t.field({ name: 'id' })], - }), - }), - ], - }), - variables: { - input: { - id: where.id, - }, - }, - }; -} - -export function buildCustomDocument( - operationType: 'query' | 'mutation', - operationName: string, - fieldName: string, - select: TSelect, - args: TArgs, - variableDefinitions: Array<{ name: string; type: string }> -): { document: string; variables: Record } { - let actualSelect = select; - let isConnection = false; - - if (select && typeof select === 'object' && 'select' in select) { - const wrapper = select as { select?: TSelect; connection?: boolean }; - if (wrapper.select) { - actualSelect = wrapper.select; - isConnection = wrapper.connection === true; - } - } - - const selections = actualSelect - ? buildSelections(actualSelect as Record) - : []; + let sourceContent = fs.readFileSync(sourceFilePath, 'utf-8'); - const variableDefs = variableDefinitions.map((definition) => - t.variableDefinition({ - variable: t.variable({ name: definition.name }), - type: parseType(definition.type), - }) - ); - const fieldArgs = variableDefinitions.map((definition) => - t.argument({ - name: definition.name, - value: t.variable({ name: definition.name }), - }) - ); - - const fieldSelections = isConnection - ? buildConnectionSelections(selections) - : selections; - - const document = t.document({ - definitions: [ - t.operationDefinition({ - operation: operationType, - name: operationName, - variableDefinitions: variableDefs.length ? variableDefs : undefined, - selectionSet: t.selectionSet({ - selections: [ - t.field({ - name: fieldName, - args: fieldArgs.length ? fieldArgs : undefined, - selectionSet: fieldSelections.length - ? t.selectionSet({ selections: fieldSelections }) - : undefined, - }), - ], - }), - }), - ], - }); - - return { - document: print(document), - variables: (args ?? {}) as Record, - }; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function buildArgs(args: Array): ArgumentNode[] { - return args.filter((arg): arg is ArgumentNode => arg !== null); -} - -function buildOptionalArg( - name: string, - value: number | string | undefined -): ArgumentNode | null { - if (value === undefined) { - return null; - } - const valueNode = - typeof value === 'number' - ? t.intValue({ value: value.toString() }) - : t.stringValue({ value }); - return t.argument({ name, value: valueNode }); -} - -function buildEnumListArg( - name: string, - values: string[] | undefined -): ArgumentNode | null { - if (!values || values.length === 0) { - return null; - } - return t.argument({ - name, - value: t.listValue({ - values: values.map((value) => buildEnumValue(value)), - }), - }); -} - -function buildEnumValue(value: string): EnumValueNode { - return { - kind: 'EnumValue', - value, - }; -} - -function buildPageInfoSelections(): FieldNode[] { - return [ - t.field({ name: 'hasNextPage' }), - t.field({ name: 'hasPreviousPage' }), - t.field({ name: 'startCursor' }), - t.field({ name: 'endCursor' }), - ]; -} - -function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { - return [ - t.field({ - name: 'nodes', - selectionSet: t.selectionSet({ selections: nodeSelections }), - }), - t.field({ name: 'totalCount' }), - t.field({ - name: 'pageInfo', - selectionSet: t.selectionSet({ selections: buildPageInfoSelections() }), - }), - ]; -} - -interface VariableSpec { - varName: string; - argName?: string; - typeName: string; - value: unknown; -} - -interface InputMutationConfig { - operationName: string; - mutationField: string; - inputTypeName: string; - resultSelections: FieldNode[]; -} - -function buildInputMutationDocument(config: InputMutationConfig): string { - const document = t.document({ - definitions: [ - t.operationDefinition({ - operation: 'mutation', - name: config.operationName + 'Mutation', - variableDefinitions: [ - t.variableDefinition({ - variable: t.variable({ name: 'input' }), - type: parseType(config.inputTypeName + '!'), - }), - ], - selectionSet: t.selectionSet({ - selections: [ - t.field({ - name: config.mutationField, - args: [ - t.argument({ - name: 'input', - value: t.variable({ name: 'input' }), - }), - ], - selectionSet: t.selectionSet({ - selections: config.resultSelections, - }), - }), - ], - }), - }), - ], - }); - return print(document); -} - -function addVariable( - spec: VariableSpec, - definitions: VariableDefinitionNode[], - args: ArgumentNode[], - variables: Record -): void { - if (spec.value === undefined) return; - - definitions.push( - t.variableDefinition({ - variable: t.variable({ name: spec.varName }), - type: parseType(spec.typeName), - }) - ); - args.push( - t.argument({ - name: spec.argName ?? spec.varName, - value: t.variable({ name: spec.varName }), - }) - ); - variables[spec.varName] = spec.value; -} - -function buildValueAst( - value: unknown -): - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | EnumValueNode { - if (value === null) { - return t.nullValue(); - } - - if (typeof value === 'boolean') { - return t.booleanValue({ value }); - } - - if (typeof value === 'number') { - return Number.isInteger(value) - ? t.intValue({ value: value.toString() }) - : t.floatValue({ value: value.toString() }); - } - - if (typeof value === 'string') { - return t.stringValue({ value }); - } - - if (Array.isArray(value)) { - return t.listValue({ - values: value.map((item) => buildValueAst(item)), - }); - } + // Replace the source file header comment with the generated file header + const headerComment = `/** + * Query Builder - Builds and executes GraphQL operations + * + * This is the RUNTIME code that gets copied to generated output. + * It uses gql-ast to build GraphQL documents programmatically. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated ORM clients. + */`; - if (typeof value === 'object' && value !== null) { - const obj = value as Record; - return t.objectValue({ - fields: Object.entries(obj).map(([key, val]) => - t.objectField({ - name: key, - value: buildValueAst(val), - }) - ), - }); - } + const generatedHeader = `/** + * Query Builder - Builds and executes GraphQL operations + * @generated by @constructive-io/graphql-codegen + * DO NOT EDIT - changes will be overwritten + */`; - throw new Error('Unsupported value type: ' + typeof value); -} -`; + sourceContent = sourceContent.replace(headerComment, generatedHeader); return { fileName: 'query-builder.ts', - content, + content: sourceContent, }; } diff --git a/graphql/codegen/src/cli/codegen/orm/client.ts b/graphql/codegen/src/cli/codegen/orm/client.ts new file mode 100644 index 000000000..dcd06a383 --- /dev/null +++ b/graphql/codegen/src/cli/codegen/orm/client.ts @@ -0,0 +1,99 @@ +/** + * ORM Client - Type stub for compile-time type checking + * + * This is a stub file that provides type definitions for query-builder.ts + * during compilation. The actual client.ts is generated at codegen time + * from the generateOrmClientFile() function in client-generator.ts. + * + * @internal This file is NOT part of the generated output + */ + +export interface OrmClientConfig { + endpoint: string; + headers?: Record; +} + +export interface GraphQLError { + message: string; + locations?: { line: number; column: number }[]; + path?: (string | number)[]; + extensions?: Record; +} + +export class GraphQLRequestError extends Error { + constructor( + public readonly errors: GraphQLError[], + public readonly data: unknown = null + ) { + const messages = errors.map((e) => e.message).join('; '); + super(`GraphQL Error: ${messages}`); + this.name = 'GraphQLRequestError'; + } +} + +export type QueryResult = + | { ok: true; data: T; errors: undefined } + | { ok: false; data: null; errors: GraphQLError[] }; + +export class OrmClient { + private endpoint: string; + private headers: Record; + + constructor(config: OrmClientConfig) { + this.endpoint = config.endpoint; + this.headers = config.headers ?? {}; + } + + async execute( + document: string, + variables?: Record + ): Promise> { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...this.headers, + }, + body: JSON.stringify({ + query: document, + variables: variables ?? {}, + }), + }); + + if (!response.ok) { + return { + ok: false, + data: null, + errors: [{ message: `HTTP ${response.status}: ${response.statusText}` }], + }; + } + + const json = (await response.json()) as { + data?: T; + errors?: GraphQLError[]; + }; + + if (json.errors && json.errors.length > 0) { + return { + ok: false, + data: null, + errors: json.errors, + }; + } + + return { + ok: true, + data: json.data as T, + errors: undefined, + }; + } + + setHeaders(headers: Record): void { + this.headers = { ...this.headers, ...headers }; + } + + getEndpoint(): string { + return this.endpoint; + } +} diff --git a/graphql/codegen/src/cli/codegen/orm/query-builder.ts b/graphql/codegen/src/cli/codegen/orm/query-builder.ts new file mode 100644 index 000000000..62e9d877d --- /dev/null +++ b/graphql/codegen/src/cli/codegen/orm/query-builder.ts @@ -0,0 +1,670 @@ +/** + * Query Builder - Builds and executes GraphQL operations + * + * This is the RUNTIME code that gets copied to generated output. + * It uses gql-ast to build GraphQL documents programmatically. + * + * NOTE: This file is read at codegen time and written to output. + * Any changes here will affect all generated ORM clients. + */ + +import * as t from 'gql-ast'; +import { parseType, print } from 'graphql'; +import type { + ArgumentNode, + FieldNode, + VariableDefinitionNode, + EnumValueNode, +} from 'graphql'; +import { OrmClient, QueryResult, GraphQLRequestError } from './client'; + +export interface QueryBuilderConfig { + client: OrmClient; + operation: 'query' | 'mutation'; + operationName: string; + fieldName: string; + document: string; + variables?: Record; +} + +export class QueryBuilder { + private config: QueryBuilderConfig; + + constructor(config: QueryBuilderConfig) { + this.config = config; + } + + /** + * Execute the query and return a discriminated union result + * Use result.ok to check success, or .unwrap() to throw on error + */ + async execute(): Promise> { + return this.config.client.execute( + this.config.document, + this.config.variables + ); + } + + /** + * Execute and unwrap the result, throwing GraphQLRequestError on failure + * @throws {GraphQLRequestError} If the query returns errors + */ + async unwrap(): Promise { + const result = await this.execute(); + if (!result.ok) { + throw new GraphQLRequestError(result.errors, result.data); + } + return result.data; + } + + /** + * Execute and unwrap, returning defaultValue on error instead of throwing + */ + async unwrapOr(defaultValue: D): Promise { + const result = await this.execute(); + if (!result.ok) { + return defaultValue; + } + return result.data; + } + + /** + * Execute and unwrap, calling onError callback on failure + */ + async unwrapOrElse( + onError: (errors: import('./client').GraphQLError[]) => D + ): Promise { + const result = await this.execute(); + if (!result.ok) { + return onError(result.errors); + } + return result.data; + } + + toGraphQL(): string { + return this.config.document; + } + + getVariables(): Record | undefined { + return this.config.variables; + } +} + +// ============================================================================ +// Selection Builders +// ============================================================================ + +export function buildSelections( + select: Record | undefined +): FieldNode[] { + if (!select) { + return []; + } + + const fields: FieldNode[] = []; + + for (const [key, value] of Object.entries(select)) { + if (value === false || value === undefined) { + continue; + } + + if (value === true) { + fields.push(t.field({ name: key })); + continue; + } + + if (typeof value === 'object' && value !== null) { + const nested = value as { + select?: Record; + first?: number; + filter?: Record; + orderBy?: string[]; + connection?: boolean; + }; + + if (nested.select) { + const nestedSelections = buildSelections(nested.select); + const isConnection = + nested.connection === true || + nested.first !== undefined || + nested.filter !== undefined; + const args = buildArgs([ + buildOptionalArg('first', nested.first), + nested.filter + ? t.argument({ name: 'filter', value: buildValueAst(nested.filter) }) + : null, + buildEnumListArg('orderBy', nested.orderBy), + ]); + + if (isConnection) { + fields.push( + t.field({ + name: key, + args, + selectionSet: t.selectionSet({ + selections: buildConnectionSelections(nestedSelections), + }), + }) + ); + } else { + fields.push( + t.field({ + name: key, + args, + selectionSet: t.selectionSet({ selections: nestedSelections }), + }) + ); + } + } + } + } + + return fields; +} + +// ============================================================================ +// Document Builders +// ============================================================================ + +export function buildFindManyDocument( + operationName: string, + queryField: string, + select: TSelect, + args: { + where?: TWhere; + orderBy?: string[]; + first?: number; + last?: number; + after?: string; + before?: string; + offset?: number; + }, + filterTypeName: string, + orderByTypeName: string +): { document: string; variables: Record } { + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; + + const variableDefinitions: VariableDefinitionNode[] = []; + const queryArgs: ArgumentNode[] = []; + const variables: Record = {}; + + addVariable( + { varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { + varName: 'orderBy', + typeName: '[' + orderByTypeName + '!]', + value: args.orderBy?.length ? args.orderBy : undefined, + }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'first', typeName: 'Int', value: args.first }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'last', typeName: 'Int', value: args.last }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'after', typeName: 'Cursor', value: args.after }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'before', typeName: 'Cursor', value: args.before }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'offset', typeName: 'Int', value: args.offset }, + variableDefinitions, + queryArgs, + variables + ); + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'query', + name: operationName + 'Query', + variableDefinitions: variableDefinitions.length ? variableDefinitions : undefined, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: queryField, + args: queryArgs.length ? queryArgs : undefined, + selectionSet: t.selectionSet({ + selections: buildConnectionSelections(selections), + }), + }), + ], + }), + }), + ], + }); + + return { document: print(document), variables }; +} + +export function buildFindFirstDocument( + operationName: string, + queryField: string, + select: TSelect, + args: { where?: TWhere }, + filterTypeName: string +): { document: string; variables: Record } { + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; + + const variableDefinitions: VariableDefinitionNode[] = []; + const queryArgs: ArgumentNode[] = []; + const variables: Record = {}; + + // Always add first: 1 for findFirst + addVariable( + { varName: 'first', typeName: 'Int', value: 1 }, + variableDefinitions, + queryArgs, + variables + ); + addVariable( + { varName: 'where', argName: 'filter', typeName: filterTypeName, value: args.where }, + variableDefinitions, + queryArgs, + variables + ); + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'query', + name: operationName + 'Query', + variableDefinitions, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: queryField, + args: queryArgs, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: 'nodes', + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + }), + ], + }), + }), + ], + }); + + return { document: print(document), variables }; +} + +export function buildCreateDocument( + operationName: string, + mutationField: string, + entityField: string, + select: TSelect, + data: TData, + inputTypeName: string +): { document: string; variables: Record } { + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + [entityField]: data, + }, + }, + }; +} + +export function buildUpdateDocument( + operationName: string, + mutationField: string, + entityField: string, + select: TSelect, + where: TWhere, + data: TData, + inputTypeName: string +): { document: string; variables: Record } { + const selections = select + ? buildSelections(select as Record) + : [t.field({ name: 'id' })]; + + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ selections }), + }), + ], + }), + variables: { + input: { + id: where.id, + patch: data, + }, + }, + }; +} + +export function buildDeleteDocument( + operationName: string, + mutationField: string, + entityField: string, + where: TWhere, + inputTypeName: string +): { document: string; variables: Record } { + return { + document: buildInputMutationDocument({ + operationName, + mutationField, + inputTypeName, + resultSelections: [ + t.field({ + name: entityField, + selectionSet: t.selectionSet({ + selections: [t.field({ name: 'id' })], + }), + }), + ], + }), + variables: { + input: { + id: where.id, + }, + }, + }; +} + +export function buildCustomDocument( + operationType: 'query' | 'mutation', + operationName: string, + fieldName: string, + select: TSelect, + args: TArgs, + variableDefinitions: Array<{ name: string; type: string }> +): { document: string; variables: Record } { + let actualSelect = select; + let isConnection = false; + + if (select && typeof select === 'object' && 'select' in select) { + const wrapper = select as { select?: TSelect; connection?: boolean }; + if (wrapper.select) { + actualSelect = wrapper.select; + isConnection = wrapper.connection === true; + } + } + + const selections = actualSelect + ? buildSelections(actualSelect as Record) + : []; + + const variableDefs = variableDefinitions.map((definition) => + t.variableDefinition({ + variable: t.variable({ name: definition.name }), + type: parseType(definition.type), + }) + ); + const fieldArgs = variableDefinitions.map((definition) => + t.argument({ + name: definition.name, + value: t.variable({ name: definition.name }), + }) + ); + + const fieldSelections = isConnection + ? buildConnectionSelections(selections) + : selections; + + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: operationType, + name: operationName, + variableDefinitions: variableDefs.length ? variableDefs : undefined, + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: fieldName, + args: fieldArgs.length ? fieldArgs : undefined, + selectionSet: fieldSelections.length + ? t.selectionSet({ selections: fieldSelections }) + : undefined, + }), + ], + }), + }), + ], + }); + + return { + document: print(document), + variables: (args ?? {}) as Record, + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function buildArgs(args: Array): ArgumentNode[] { + return args.filter((arg): arg is ArgumentNode => arg !== null); +} + +function buildOptionalArg( + name: string, + value: number | string | undefined +): ArgumentNode | null { + if (value === undefined) { + return null; + } + const valueNode = + typeof value === 'number' + ? t.intValue({ value: value.toString() }) + : t.stringValue({ value }); + return t.argument({ name, value: valueNode }); +} + +function buildEnumListArg( + name: string, + values: string[] | undefined +): ArgumentNode | null { + if (!values || values.length === 0) { + return null; + } + return t.argument({ + name, + value: t.listValue({ + values: values.map((value) => buildEnumValue(value)), + }), + }); +} + +function buildEnumValue(value: string): EnumValueNode { + return { + kind: 'EnumValue', + value, + }; +} + +function buildPageInfoSelections(): FieldNode[] { + return [ + t.field({ name: 'hasNextPage' }), + t.field({ name: 'hasPreviousPage' }), + t.field({ name: 'startCursor' }), + t.field({ name: 'endCursor' }), + ]; +} + +function buildConnectionSelections(nodeSelections: FieldNode[]): FieldNode[] { + return [ + t.field({ + name: 'nodes', + selectionSet: t.selectionSet({ selections: nodeSelections }), + }), + t.field({ name: 'totalCount' }), + t.field({ + name: 'pageInfo', + selectionSet: t.selectionSet({ selections: buildPageInfoSelections() }), + }), + ]; +} + +interface VariableSpec { + varName: string; + argName?: string; + typeName: string; + value: unknown; +} + +interface InputMutationConfig { + operationName: string; + mutationField: string; + inputTypeName: string; + resultSelections: FieldNode[]; +} + +function buildInputMutationDocument(config: InputMutationConfig): string { + const document = t.document({ + definitions: [ + t.operationDefinition({ + operation: 'mutation', + name: config.operationName + 'Mutation', + variableDefinitions: [ + t.variableDefinition({ + variable: t.variable({ name: 'input' }), + type: parseType(config.inputTypeName + '!'), + }), + ], + selectionSet: t.selectionSet({ + selections: [ + t.field({ + name: config.mutationField, + args: [ + t.argument({ + name: 'input', + value: t.variable({ name: 'input' }), + }), + ], + selectionSet: t.selectionSet({ + selections: config.resultSelections, + }), + }), + ], + }), + }), + ], + }); + return print(document); +} + +function addVariable( + spec: VariableSpec, + definitions: VariableDefinitionNode[], + args: ArgumentNode[], + variables: Record +): void { + if (spec.value === undefined) return; + + definitions.push( + t.variableDefinition({ + variable: t.variable({ name: spec.varName }), + type: parseType(spec.typeName), + }) + ); + args.push( + t.argument({ + name: spec.argName ?? spec.varName, + value: t.variable({ name: spec.varName }), + }) + ); + variables[spec.varName] = spec.value; +} + +function buildValueAst( + value: unknown +): + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | EnumValueNode { + if (value === null) { + return t.nullValue(); + } + + if (typeof value === 'boolean') { + return t.booleanValue({ value }); + } + + if (typeof value === 'number') { + return Number.isInteger(value) + ? t.intValue({ value: value.toString() }) + : t.floatValue({ value: value.toString() }); + } + + if (typeof value === 'string') { + return t.stringValue({ value }); + } + + if (Array.isArray(value)) { + return t.listValue({ + values: value.map((item) => buildValueAst(item)), + }); + } + + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + return t.objectValue({ + fields: Object.entries(obj).map(([key, val]) => + t.objectField({ + name: key, + value: buildValueAst(val), + }) + ), + }); + } + + throw new Error('Unsupported value type: ' + typeof value); +}