pothos icon indicating copy to clipboard operation
pothos copied to clipboard

[Question] Integration with websocket (partykit)

Open emroot opened this issue 1 year ago • 1 comments

I'm adding partykit (websocket) to my project, and when a mutation happens, I'm sending a message to my ws server to broadcast my update to my clients (for other users to receive the update). I'm using nextjs so can't really use the smart subscriptions. I was wondering if there's some utility function to convert my data (coming from stripe api) onto the my pothos object implementation so i matches my graphql format. This may involve writing a plugin maybe but curious if there's some function that will do that already.

Here's a snippet of what I'm trying to do:

// Schema definition
export const StripeAccount = builder
  .objectRef<Stripe.Account & { spaceId: string }>('StripeAccount')
  .implement({
    fields: (t) => ({
      id: t.field({
        type: 'ID',
        resolve: (account) => encodeGlobalID('StripeAccount', account.spaceId),
      }),
      accountId: t.exposeID('id'),
      chargesEnabled: t.exposeBoolean('charges_enabled'),
      detailsSubmitted: t.exposeBoolean('details_submitted'),
      payoutsEnabled: t.exposeBoolean('payouts_enabled'),
      requirements: t.field({
        type: ['String'],
        resolve: async (account) => account.requirements?.currently_due as string[] ?? [],
      }),
      name: t.field({
        type: 'String',
        nullable: true,
        resolve: (account) => {
          return account.business_profile?.name ?? account.settings?.dashboard?.display_name ?? 'Unknown';
        },
      }),
      isOnboardingComplete: t.field({
        type: 'Boolean',
        resolve: (account) => {
          return account.details_submitted && account.charges_enabled && account.payouts_enabled;
        },
      }),
    }),
  });

// Mutation
builder.mutationField('updateStripeAccount', (t) =>
  t.field({
    authScopes: {
      admin: true,
      space: 'owner',
    },
    type: StripeAccount,
    nullable: true,
    args: {...},
    async resolve(_root, _args, { decodedSpaceId, spaceId }) {
      // Update the stripe account

      const account = await stripe.accounts.retrieve(id);

      // Websocket call
      fetch(`http://127.0.0.1:1999/parties/spaces/${spaceId}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          type: 'stripeAccount:update',
          data: account, // this is where I'd wanna convert from stripe schema to StripeAccount schema defined above
        }),
      });

      return {
        ...account,
        spaceId: space.id,
      };
    },
  }),
);

emroot avatar Sep 10 '24 21:09 emroot

this is what I have in mind:

StripeAccount.init(account).toGraphql()

emroot avatar Sep 10 '24 22:09 emroot

Hey @hayes, wanted to give you a little more context, I got something going but it's a bit buggy and some guidance would be super helpful.

Overview

This plugin extends Pothos GraphQL by adding a transformation layer that allows for controlled serialization of GraphQL objects. The plugin adds a toGraphQL method to Pothos ObjectRef instances, enabling recursive transformation of objects with features like field-level authorization, exclusion/inclusion, and depth control, all from a given prisma object.

Why This Is Needed

I'm using PartyKit websockets for real-time updates in my application. When changes occur in the backend, I need to propagate these changes to connected clients through the websocket. However, I faced a challenge: My GraphQL schema defines complex types with resolvers, auth rules, and relationships When sending data over websockets, I need the same transformations that GraphQL would apply (resolving fields, applying auth rules, etc.) There's no built-in way to "serialize" a Pothos object outside of a GraphQL query context I'm trying to solve this problem with this plugin by allowing me to take a database object (e.g., from Prisma) and transform it into its GraphQL representation with all resolvers applied and auth rules respected, making it ready to be sent over websockets while maintaining consistency with the GraphQL API. Any field that is not present in the db object is skipped, which gives me control of what I'm sending back (currently I'm only sending back the changed data only).

Key Features

  1. Field-level Authorization: Respects auth scopes defined on fields, ensuring that unauthorized fields are excluded from the output.

  2. Field Exclusion/Inclusion: Supports configuration to explicitly exclude or include specific fields during transformation.

  3. Depth Control: Prevents infinite recursion by limiting the depth of nested object transformations.

  4. Type-aware Transformations: Handles different GraphQL types appropriately:

    • Scalar types (String, Int, Boolean, etc.)
    • Object types (with nested transformation)
    • List types (transforming each item)
    • Non-nullable wrappers around these types
  5. Relay Connection Support: Detects and properly handles Relay-style connections.

  6. Resolver Integration: Uses field resolvers when available, falling back to parent object properties when needed.

Implementation Details

The plugin works by:

  1. Registering itself with the Pothos SchemaBuilder
  2. Adding a toGraphQL method to the ObjectRef prototype
  3. When toGraphQL is called on an object:
    • It retrieves the GraphQL type definition
    • Processes each field according to configuration
    • Applies auth scope checks
    • Transforms nested objects recursively
    • Handles special cases like DateTime fields

Current Challenges

While the plugin works for most cases, I'm encountering issues with:

  1. Nested Non-nullable Lists: Fields with types like GraphQLNonNull(GraphQLList(GraphQLNonNull(ObjectType))) need special handling.

  2. Generated Fields: Fields that don't exist on the database object but are generated by resolvers (like id fields that combine multiple properties) need to be properly resolved.

  3. Relay Connection Integration: Properly detecting and handling Relay connections without attempting to transform them directly.

  4. Type Detection: Reliably detecting the type of fields and their contents, especially for complex nested types.

I'm looking for guidance on best practices for handling these cases and any potential improvements to the current implementation.

Here's the code:

import SchemaBuilder, { BasePlugin, SchemaTypes, ObjectRef, BuildCache } from '@pothos/core';
import {
  GraphQLList,
  GraphQLNamedType,
  GraphQLNonNull,
  GraphQLObjectType,
  GraphQLResolveInfo,
  GraphQLType,
  isObjectType,
} from 'graphql';
import * as logger from '@ambrosia/api/utils/logger';
import './global-types';
import { GraphqlContext } from '../../context';

const pluginName = 'transform';

export interface TransformPluginOptions {
  maxDepth?: number;
}

export interface TransformOptions {
  context: GraphqlContext;
  maxDepth?: number;
  excludeFields?: string[];
}

export interface TransformConfig {
  enabled: boolean;
  exclude?: string[];
  include?: string[]; // Array of field names to include from resolvers
  maxDepth?: number;
}

// Store the schema builder instance
let schemaBuilder: BuildCache<any>;

function unwrap(type: GraphQLType): GraphQLNamedType {
  if (type instanceof GraphQLNonNull) {
    return unwrap(type.ofType as GraphQLType);
  }

  if (type instanceof GraphQLList) {
    return unwrap(type.ofType);
  }

  return type;
}

function isList(type: GraphQLType): boolean {
  return (
    type instanceof GraphQLList ||
    (type instanceof GraphQLNonNull && type.ofType instanceof GraphQLList)
  );
}

function isObject(type: GraphQLType): boolean {
  return (
    type instanceof GraphQLObjectType ||
    (type instanceof GraphQLNonNull && type.ofType instanceof GraphQLObjectType)
  );
}

export class PothosTransformPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
  constructor(buildCache: BuildCache<Types>) {
    super(buildCache, pluginName);
    logger.log('🔌 Init:', {
      builder: buildCache,
      hasBuilder: !!buildCache,
      toSchema: typeof buildCache?.builder === 'function',
    });

    // Store the builder instance
    schemaBuilder = buildCache;
    logger.log('📝 Stored:', {
      hasBuilder: !!schemaBuilder,
    });

    // Add toGraphQL to ObjectRef prototype
    Object.defineProperty(ObjectRef.prototype, 'toGraphQL', {
      configurable: true,
      writable: true,
      value: async function toGraphQL(parent: any, options: TransformOptions) {
        try {
          if (!parent) return null;

          // Validate that context is provided
          if (!options.context) {
            throw new Error('Context is required for toGraphQL transformation');
          }

          const authResult = await schemaBuilder?.builder?.options?.scopeAuth?.authScopes(
            options.context,
          );

          logger.log('🔍 Transform:', {
            type: this.name,
            hasBuilder: !!schemaBuilder,
          });

          if (!schemaBuilder) {
            logger.error('❌ No builder');
            return null;
          }

          // Cache for auth scope results
          const authCache: Record<string, boolean> = {};

          const checkScopeWithCache = async (
            scopeType: string,
            scopeValue: any,
          ): Promise<boolean> => {
            const cacheKey = `${scopeType}:${JSON.stringify(scopeValue)}`;
            if (cacheKey in authCache) {
              return authCache[cacheKey];
            }

            // Special handling for negative scopes (e.g., public: false)
            // If scopeValue is false, we want to invert the result
            const isNegativeScope = scopeValue === false;

            const scopeChecker = authResult?.[scopeType];
            let result = false;

            if (typeof scopeChecker === 'boolean') {
              // For boolean scopes, if scopeValue is false, we want to invert the result
              result = isNegativeScope ? !scopeChecker : scopeChecker;
            } else if (typeof scopeChecker === 'function') {
              try {
                // For function scopes, we pass the actual value (which might be a string, object, etc.)
                // If scopeValue is false, we invert the result
                const checkResult = await scopeChecker(isNegativeScope ? true : scopeValue);
                result = isNegativeScope ? !checkResult : checkResult;
              } catch (error) {
                logger.warn('⚠️ Scope check failed:', { scopeType, error });
                result = false;
              }
            }

            authCache[cacheKey] = result;
            return result;
          };

          const type = schemaBuilder.types?.get(this.name);
          if (!type || !isObjectType(type)) {
            // Instead of returning null, we'll check if this is a scalar type
            // For scalar types like String, we should just return the parent value
            if (['String', 'Int', 'Float', 'Boolean', 'ID', 'DateTime'].includes(this.name)) {
              return parent;
            }

            logger.log(`❌ Invalid type: ${this.name}`);
            return null;
          }

          const fields = type.getFields();
          const result: any = {
            __typename: this.name,
          };

          if (options.maxDepth != null && options.maxDepth <= 0) {
            logger.log(`⚠️ Max depth: ${this.name}`);
            for (const [fieldName, field] of Object.entries(fields)) {
              if (fieldName in parent && !field.resolve) {
                result[fieldName] = parent[fieldName];
              }
            }
            return result;
          }

          const parentType = unwrap(type);
          const config = schemaBuilder?.builder?.configStore?.typeConfigs?.get(parentType.name)
            ?.pothosOptions as PothosSchemaTypes.ObjectTypeOptions<any, any>;
          const excludeFields =
            typeof config?.transform === 'boolean' ? [] : config?.transform?.exclude ?? [];
          const includeFields =
            typeof config?.transform === 'boolean' ? [] : config?.transform?.include ?? [];

          // Create a list of all fields to process, including include fields
          const fieldsToProcess = new Set<string>();

          // Add all fields from the GraphQL schema
          for (const fieldName of Object.keys(fields)) {
            fieldsToProcess.add(fieldName);
          }

          // Add include fields if they're not already in the list
          for (const fieldName of includeFields) {
            fieldsToProcess.add(fieldName);
          }

          // Process all fields
          for (const fieldName of fieldsToProcess) {
            try {
              logger.log(`📝 Processing field ${this.name}.${fieldName}`);

              // Skip excluded fields
              if (excludeFields.includes(fieldName)) {
                logger.log(`  - Skipping excluded field ${this.name}.${fieldName}`);
                continue;
              }

              // Get the field from the schema
              const field = fields[fieldName];
              if (!field) {
                logger.log(`  - Field ${fieldName} not found in schema, skipping`);
                continue;
              }

              // Check auth scopes first if they exist
              const authScopes =
                (field.extensions as any)?.pothosOptions?.authScopes ||
                (field.extensions as any)?.authScopes;
              if (authScopes && options.context) {
                try {
                  // Check each scope type
                  let isAuthorized = false;
                  for (const [scopeType, scopeValue] of Object.entries(authScopes)) {
                    const hasAccess = await checkScopeWithCache(scopeType, scopeValue);
                    if (hasAccess) {
                      isAuthorized = true;
                      break;
                    }
                  }

                  if (!isAuthorized) {
                    // Skip this field if no scope checks passed
                    continue;
                  }
                } catch (error) {
                  // Skip this field if auth check fails
                  logger.warn('⚠️ Auth check failed:', error);
                  continue;
                }
              }

              // ALWAYS try to use the resolver first if it exists
              try {
                logger.log(`  - Using resolver for ${this.name}.${fieldName}`);

                const info: GraphQLResolveInfo = {
                  fieldName,
                  parentType: type,
                  returnType: field.type,
                  // @ts-ignore
                  path: { key: fieldName },
                  // schema: schemaBuilder.builder.toSchema(), // Skip schema for now since it's not available
                };

                // Check if this is a Relay connection field
                const isRelayConnection =
                  // Check if the field has the typical Relay connection arguments
                  field.args?.some((arg) =>
                    ['before', 'after', 'first', 'last'].includes(arg.name),
                  ) &&
                  // Check if the field type follows the Relay connection pattern
                  (field.type instanceof GraphQLNonNull
                    ? unwrap(field.type).name?.endsWith('Connection')
                    : unwrap(field.type)?.name?.endsWith('Connection'));

                if (isRelayConnection) {
                  // Skip direct transformation for Relay connections
                  // Let the resolver handle it
                  continue;
                }

                // Check that the property is defined on the parent object
                if (field.type instanceof GraphQLObjectType && fieldName in parent) {
                  // In this case we don't resolve the value, we try to transform the object ref
                  const objectRef = schemaBuilder.builder.objectRef(
                    field.type.name,
                  ) as unknown as PothosSchemaTypes.ObjectRef<any, any>;
                  if (objectRef && 'toGraphQL' in objectRef) {
                    result[fieldName] = await objectRef.toGraphQL(parent[fieldName], {
                      ...options,
                      maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
                    });
                    continue;
                  }
                }

                const resolvedValue =
                  (await field?.resolve?.(parent, {}, options.context, info)) ?? parent[fieldName];

                if (isList(field.type)) {
                  const type = unwrap(field.type);
                  // Check if the item type is a scalar type
                  const isScalarType = [
                    'String',
                    'Int',
                    'Float',
                    'Boolean',
                    'ID',
                    'DateTime',
                    'Date',
                    'Time',
                    'JSON',
                    'JSONObject',
                    'JSONB',
                    'BigInt',
                    'Decimal',
                    'URL',
                    'URI',
                    'Email',
                    'UUID',
                    'GUID',
                    'Upload',
                  ].includes(type.name);

                  if (isScalarType) {
                    // For arrays of scalar types, use the values directly
                    result[fieldName] = resolvedValue;
                    continue;
                  }

                  const objectRef = schemaBuilder.builder.objectRef(
                    type.name,
                  ) as unknown as PothosSchemaTypes.ObjectRef<any, any>;
                  if (objectRef && 'toGraphQL' in objectRef) {
                    if (!resolvedValue?.length) {
                      result[fieldName] = [];
                      continue;
                    }
                    const transformed = await Promise.all(
                      resolvedValue?.map(
                        (item: any[]) =>
                          objectRef.toGraphQL(item, {
                            ...options,
                            maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
                          }) || [],
                      ),
                    );
                    result[fieldName] = transformed;
                    continue;
                  } else {
                    // If we can't transform the items, use the array as is
                    result[fieldName] = resolvedValue;
                    continue;
                  }
                }
                if (isObject(field.type)) {
                  const type = unwrap(field.type);
                  // Get object ref for the type
                  const objectRef = schemaBuilder.builder.objectRef(type.name);
                  if (objectRef && 'toGraphQL' in objectRef) {
                    // To this:
                    const transformed = await (
                      objectRef as unknown as PothosSchemaTypes.ObjectRef<any, any>
                    ).toGraphQL(resolvedValue, {
                      ...options,
                      maxDepth: options.maxDepth ? options.maxDepth - 1 : undefined,
                    });
                    result[fieldName] = transformed;
                    continue;
                  }
                }

                if (type instanceof GraphQLObjectType && type.name === 'DateTime') {
                  result[fieldName] = new Date(resolvedValue);
                  continue;
                }

                // Skip null values
                if (resolvedValue === undefined) continue;

                // Clean excluded fields from the resolved value based on its type
                let cleanedValue = resolvedValue;

                // If we couldn't transform the cleaned value, use it directly
                result[fieldName] = cleanedValue;
                continue;
              } catch (error) {
                // logger.warn(`⚠️ Resolver failed for ${this.name}.${fieldName}:`, error);
                // Fall back to parent value if resolver fails
              }

              // Skip resolver fields if no context
              if (!options.context) {
                continue;
              }

              // We should never reach here since we already tried the resolver above
              // but keeping this as a fallback
              if (field.resolve) {
                // ... existing fallback resolver code ...
              }
            } catch (error) {
              // Skip field if any error occurs
              // logger.warn(`⚠️ Error processing ${this.name}.${fieldName}:`, error);
              continue;
            }
          }

          return result;
        } catch (error) {
          logger.error('❌ Transform error', error);
          throw error;
        }
      },
    });
  }

  beforeBuildSchema() {
    logger.log('🔍 Setup:', {
      hasToGraphQL: typeof ObjectRef.prototype.toGraphQL === 'function',
      hasBuilder: !!schemaBuilder,
    });
  }
}

// Register the plugin
logger.log('🔧 Registering plugin');
SchemaBuilder.registerPlugin(pluginName, PothosTransformPlugin);

export default pluginName;

Examples:


const product = await prisma.product.findUnique({
  where: {
    id: xxx,
  }
})

export const ProductRef = builder.prismaNode('Product', {
  id: { field: 'id' },
  findUnique: (id) => ({ id }),
  fields: (t) => ({
    createdAt: t.expose('createdAt', { type: 'DateTime' }),
    updatedAt: t.expose('updatedAt', { type: 'DateTime', nullable: true }),
    ....
});

ProductRef.toGraphQL(record, {
  context,
  maxDepth: 2,
});

emroot avatar Mar 12 '25 05:03 emroot

PS: I used cursor to help me write some of the code as the pothos internals are a bit too complex for me to grasp everything.

emroot avatar Mar 12 '25 05:03 emroot

Hey, this looks pretty cool!

Sorry I somehow missed this issue when it was first opened.

I think my first though about this would be that using GraphQL directly to do this. I'm not sure how many or how complex your selections are, so maybe its too complicated to hand-write the queries but there are a few ways to do this.

This simplest thing I can think of would be to add queries to your schema that take your data as the root object and return it.-graph plugin to build schemas with/without these transform fields

This would be something like:

import { execute, parse } from 'graphql';

builder.queryField('transformStripeAccount', t => t.field({
  subGraphs: ['transforms'],
  type: StripeAccount,
  resolve: (root) => root as any,
}))


const transformSchema = builder.toSchema({ subGraph: ['transforms'] })

async function transformStripeAccount(context, account) {
  const result = await execute({
    schema: transformSchema,
    contextValue: context,
    rootValue: 
    document: parse(`
    query {
      transformStripeAccount {
        ...your selections for StripAccount
      }
    }`),
  });
}

This approach actually uses GraphQL to execute your queries so all transforms and auth checks are applied exactly as they are when querying though the graphql endpoint, and doesn't require you to do any complex data resolving yourself.

The downside is that you actually need a query to execute. If its just a couple of cases, it probably isn't hard to just write out the selection you want. But, if that doesn't sound like a good option, its probably a LOT easier to generate the query programmatically that it is to resolve all the data manually, so you could also just write a function that takes a GraphQL type and generates a recursive selection/query for that type.

To get back to your plugin idea, you could probably also implement a similar API that uses graphql to resolve the actual query.

All you would need to do is something like this:

async function toGraphQL(
  type: GraphQLType,
  data: unknown,
  contextValue: unknown
) {
  const Query = new GraphQLObjectType({
    name: 'Query',
    fields: {
      transform: {
        type: type as GraphQLOutputType,
        resolve: () => data,
      },
    },
  });

  const schema = new GraphQLSchema({
    query: Query,
  });

  return execute({
    schema,
    contextValue,
    document: parse(`
    query {
      transform {
        id
        #your selection
      }
    }`),
  });
}

Again, this requires either manually defining your selection, or generating the selection, but the logic to execute it should be very simple

hayes avatar Mar 12 '25 06:03 hayes

yeah that makes sense. Thank you so much for the direction. This is what I came up with with the help of o3. Not sure if that's a plugin that might be helpful to others.

Usage

export const ProductRef = builder.prismaNode('Product', {
  transform: {
    exclude: ['password'],
    include: ['some_custom_resolver'],
  },
})
ProductRef.toGraphql(product, {
  maxDepth: 2,
  ctx: context,
})

Code

import SchemaBuilder, { BasePlugin, SchemaTypes, ObjectRef, BuildCache } from '@pothos/core';
import {
  GraphQLList,
  GraphQLNamedType,
  GraphQLNonNull,
  GraphQLObjectType,
  GraphQLOutputType,
  GraphQLSchema,
  GraphQLType,
  execute,
  isObjectType,
  parse,
} from 'graphql';
import * as logger from '@ambrosia/api/utils/logger';
import './global-types';
import { GraphqlContext } from '../../context';

const pluginName = 'transform';

export interface TransformPluginOptions {
  maxDepth?: number;
}

export interface TransformOptions {
  context: GraphqlContext;
  maxDepth?: number;
  excludeFields?: string[];
  includeFields?: string[] | 'all' | '*';
}

export interface TransformConfig {
  enabled: boolean;
  exclude?: string[];
  include?: string[] | 'all' | '*';
  maxDepth?: number;
}

// Cache for the schema builder and built schema
let schemaBuilder: BuildCache<any>;
let builtSchema: GraphQLSchema | null = null;
let schemaBuilt = false;

// --- Memoized unwrap ---
// Cache previously unwrapped types to avoid redundant recursion.
const unwrapCache = new WeakMap<GraphQLType, GraphQLNamedType>();
function unwrap(type: GraphQLType): GraphQLNamedType {
  if (unwrapCache.has(type)) return unwrapCache.get(type)!;
  const result =
    type instanceof GraphQLNonNull || type instanceof GraphQLList ? unwrap(type.ofType) : type;
  unwrapCache.set(type, result);
  return result;
}

// --- Top-level Field Filter Helpers ---
function isExcluded(fieldName: string, combinedExcludeFields: string[]): boolean {
  return combinedExcludeFields.includes(fieldName) || fieldName === '__typename';
}

function fieldExists(fields: Record<string, any>, fieldName: string): boolean {
  return !!fields[fieldName];
}

function getUniqueIncludeFields(globalIncludes: any, typeIncludes: any): string[] {
  return Array.from(
    new Set([
      ...(Array.isArray(globalIncludes) ? globalIncludes : []),
      ...(Array.isArray(typeIncludes) ? typeIncludes : []),
    ]),
  );
}

function shouldIncludeAllFields(globalIncludes: any, typeIncludes: any): boolean {
  return (
    globalIncludes === 'all' ||
    globalIncludes === '*' ||
    typeIncludes === 'all' ||
    typeIncludes === '*'
  );
}

// --- Streamlined Field Inclusion ---
function determineFieldsToInclude(
  parentObject: any,
  fields: Record<string, any>,
  combinedExcludeFields: string[],
  uniqueExplicitIncludeFields: string[],
  includeAllFields: boolean,
  currentDepth: number,
): string[] {
  const fieldNames: Set<string> = new Set();

  if (parentObject) {
    const addFields = (obj: any) => {
      if (obj && typeof obj === 'object') {
        Object.keys(obj).forEach((key) => {
          if (!isExcluded(key, combinedExcludeFields) && fieldExists(fields, key)) {
            fieldNames.add(key);
          }
        });
      }
    };

    if (Array.isArray(parentObject)) {
      parentObject.forEach((item) => {
        addFields(item);
        if (item && typeof item === 'object') {
          Object.values(item).forEach((val) => {
            if (val && typeof val === 'object' && !Array.isArray(val)) {
              addFields(val);
            }
          });
        }
      });
    } else {
      addFields(parentObject);
      Object.values(parentObject).forEach((val) => {
        if (val && typeof val === 'object' && !Array.isArray(val)) {
          addFields(val);
        }
      });
    }

    uniqueExplicitIncludeFields.forEach((field) => {
      if (
        !fieldNames.has(field) &&
        !isExcluded(field, combinedExcludeFields) &&
        fieldExists(fields, field)
      ) {
        fieldNames.add(field);
      }
    });

    if (includeAllFields) {
      Object.keys(fields).forEach((field) => {
        if (!fieldNames.has(field) && !isExcluded(field, combinedExcludeFields)) {
          fieldNames.add(field);
        }
      });
    }

    if (fieldExists(fields, 'id') && !isExcluded('id', combinedExcludeFields)) {
      fieldNames.add('id');
    }
  } else if (currentDepth === 0) {
    const baseFields = includeAllFields
      ? Object.keys(fields).filter((name) => !isExcluded(name, combinedExcludeFields))
      : uniqueExplicitIncludeFields.filter(
          (name) => fieldExists(fields, name) && !isExcluded(name, combinedExcludeFields),
        );
    baseFields.forEach((f) => fieldNames.add(f));
  } else {
    Object.keys(fields).forEach((f) => {
      if (!isExcluded(f, combinedExcludeFields)) fieldNames.add(f);
    });
  }

  return Array.from(fieldNames);
}

function shouldSkipField(field: any): boolean {
  return (
    field.args &&
    field.args.length > 0 &&
    field.args.some(
      (arg: any) => arg.type instanceof GraphQLNonNull && arg.defaultValue === undefined,
    )
  );
}

function getTypeConfig(typeName: string): {
  maxDepth: number;
  typeExcludeFields: string[];
  typeIncludeFields: string[] | 'all' | '*';
} {
  const typeConfig = schemaBuilder?.builder?.configStore?.typeConfigs?.get(typeName);
  const transformConfig = (typeConfig?.pothosOptions as any)?.transform as
    | TransformConfig
    | undefined;
  return {
    maxDepth: transformConfig?.maxDepth ?? 3,
    typeExcludeFields:
      transformConfig && typeof transformConfig !== 'boolean' ? transformConfig.exclude || [] : [],
    typeIncludeFields:
      transformConfig && typeof transformConfig !== 'boolean' ? transformConfig.include || [] : [],
  };
}

function buildConnectionQuery(
  fields: Record<string, any>,
  maxDepth: number,
  currentDepth: number,
  combinedExcludeFields: string[],
  includeAllFields: boolean,
): string {
  let query = '__typename\n';
  if (fields.edges) {
    const edgesType = unwrap(fields.edges.type);
    if (isObjectType(edgesType)) {
      const edgeFields = edgesType.getFields();
      if (edgeFields.node) {
        const nodeType = unwrap(edgeFields.node.type);
        if (isObjectType(nodeType)) {
          // eslint-disable-next-line @typescript-eslint/no-use-before-define
          const nodeQuery = buildQuery(nodeType, {
            currentDepth: currentDepth + 1,
            rootMaxDepth: maxDepth,
            rootExcludeFields: combinedExcludeFields,
            rootIncludeFields: includeAllFields ? 'all' : [],
            parentObject: null,
          });
          if (nodeQuery) {
            query += `edges {\n  __typename\n  node {\n${nodeQuery}  }\n}\n`;
          }
        }
      }
    }
  }
  if (fields.pageInfo) {
    query +=
      'pageInfo {\n  __typename\n  hasNextPage\n  hasPreviousPage\n  startCursor\n  endCursor\n}\n';
  }
  if (fields.totalCount) {
    query += 'totalCount\n';
  }
  return query;
}

function buildArrayFieldQuery(
  fieldName: string,
  arrayItemType: GraphQLObjectType,
  nestedParentObject: any[],
  currentDepth: number,
  maxDepth: number,
  combinedExcludeFields: string[],
  includeAllFields: boolean,
  uniqueExplicitIncludeFields: string[],
): string {
  const arrayItemFields = arrayItemType.getFields();
  const fieldNames = determineFieldsToInclude(
    nestedParentObject,
    arrayItemFields,
    combinedExcludeFields,
    uniqueExplicitIncludeFields,
    includeAllFields,
    currentDepth + 1,
  );
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
  const arrayItemQuery = buildQuery(arrayItemType, {
    currentDepth: currentDepth + 1,
    rootMaxDepth: maxDepth,
    rootExcludeFields: combinedExcludeFields,
    rootIncludeFields: includeAllFields ? 'all' : fieldNames,
    parentObject: nestedParentObject[0] || null,
  });
  return arrayItemQuery ? `${fieldName} {\n${arrayItemQuery}}\n` : '';
}

function buildQuery(
  type: GraphQLObjectType,
  context: {
    parentObject?: any;
    currentDepth?: number;
    rootMaxDepth?: number;
    rootExcludeFields?: string[];
    rootIncludeFields?: string[] | 'all' | '*';
  } = {},
): string {
  const {
    parentObject = null,
    currentDepth = 0,
    rootMaxDepth,
    rootExcludeFields = [],
    rootIncludeFields = [],
  } = context;

  const typeName = type.name;
  const fields = type.getFields();
  const { maxDepth: typeMaxDepth, typeExcludeFields, typeIncludeFields } = getTypeConfig(typeName);
  const maxDepth = rootMaxDepth || typeMaxDepth;
  if (currentDepth > maxDepth) return '';

  const combinedExcludeFields = [...rootExcludeFields, ...typeExcludeFields];
  const includeAllFields = shouldIncludeAllFields(rootIncludeFields, typeIncludeFields);
  const uniqueExplicitIncludeFields = getUniqueIncludeFields(rootIncludeFields, typeIncludeFields);
  const fieldNames = determineFieldsToInclude(
    parentObject,
    fields,
    combinedExcludeFields,
    uniqueExplicitIncludeFields,
    includeAllFields,
    currentDepth,
  );

  logger.log('🔍 Executing query:', {
    typeName,
    fieldNames,
    includeFields: uniqueExplicitIncludeFields,
    excludeFields: combinedExcludeFields,
  });

  let query = '__typename\n';
  if (typeName.endsWith('Connection')) {
    return buildConnectionQuery(
      fields,
      maxDepth,
      currentDepth,
      combinedExcludeFields,
      includeAllFields,
    );
  }

  for (const fieldName of fieldNames) {
    if (fieldName === '__typename') continue;
    const field = fields[fieldName];
    if (!field || shouldSkipField(field)) continue;

    const fieldType = unwrap(field.type);
    if (!isObjectType(fieldType)) {
      query += `${fieldName}\n`;
      continue;
    }

    const isFieldConnectionType = fieldType.name.endsWith('Connection');
    const nestedParentObject =
      parentObject && parentObject[fieldName] ? parentObject[fieldName] : null;

    if (Array.isArray(nestedParentObject)) {
      query += buildArrayFieldQuery(
        fieldName,
        fieldType as GraphQLObjectType,
        nestedParentObject,
        currentDepth,
        maxDepth,
        combinedExcludeFields,
        includeAllFields,
        uniqueExplicitIncludeFields,
      );
      continue;
    }

    const subQuery = buildQuery(fieldType as GraphQLObjectType, {
      currentDepth: currentDepth + 1,
      rootMaxDepth: maxDepth,
      rootExcludeFields: combinedExcludeFields,
      rootIncludeFields: includeAllFields ? 'all' : [],
      parentObject: nestedParentObject,
    });
    if (subQuery) {
      query += `${fieldName} {\n${subQuery}}\n`;
    } else if (isFieldConnectionType) {
      query += `${fieldName} {\n  __typename\n  edges {\n    __typename\n    node {\n      __typename\n      id\n    }\n  }\n}\n`;
    } else {
      query += `${fieldName} { __typename id }\n`;
    }
  }
  return query;
}

export class PothosTransformPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
  constructor(buildCache: BuildCache<Types>) {
    super(buildCache, pluginName);
    logger.log('🔌 Init:', {
      builder: buildCache,
      hasBuilder: !!buildCache,
      toSchema: typeof buildCache?.builder === 'function',
    });
    schemaBuilder = buildCache;
    logger.log('📝 Stored:', { hasBuilder: !!schemaBuilder });

    Object.defineProperty(ObjectRef.prototype, 'toGraphQL', {
      configurable: true,
      writable: true,
      value: async function toGraphQL(parent: any, options: TransformOptions) {
        try {
          if (!parent) return null;
          if (!options.context) throw new Error('Context is required for toGraphQL transformation');

          const typeName = this.name;
          if (!schemaBuilt) {
            try {
              builtSchema = schemaBuilder.builder.toSchema();
              schemaBuilt = true;
              logger.log('📝 Schema built:', {
                hasSchema: !!builtSchema,
                typeCount: builtSchema ? Object.keys(builtSchema.getTypeMap()).length : 0,
              });
            } catch (error) {
              logger.error('❌ Failed to build schema:', error);
              return null;
            }
          }
          if (!builtSchema) {
            logger.error('❌ No schema available');
            return null;
          }

          const type = builtSchema.getType(typeName);
          if (!type || !isObjectType(type)) {
            logger.error(`❌ Invalid type: ${typeName}`);
            return null;
          }

          const Query = new GraphQLObjectType({
            name: 'Query',
            fields: {
              transform: {
                type: type as GraphQLOutputType,
                resolve: () => parent,
              },
            },
          });
          const tempSchema = new GraphQLSchema({ query: Query });
          const { maxDepth, typeExcludeFields, typeIncludeFields } = getTypeConfig(typeName);
          const queryFields = buildQuery(type as GraphQLObjectType, {
            rootMaxDepth: options.maxDepth || maxDepth,
            rootExcludeFields: [...(options.excludeFields || []), ...typeExcludeFields],
            rootIncludeFields: options.includeFields || typeIncludeFields,
            currentDepth: 0,
            parentObject: parent,
          });
          if (!queryFields) {
            logger.error(`❌ Failed to build query for type: ${typeName}`);
            return null;
          }
          const query = `
            query {
              transform {
                ${queryFields}
              }
            }
          `;
          const result = await execute({
            schema: tempSchema,
            document: parse(query),
            contextValue: options.context,
          });
          if (result.errors) {
            logger.error('❌ GraphQL execution errors:', result.errors);
          }
          return result.data?.transform || null;
        } catch (error) {
          logger.error('❌ Transform error:', error);
          return null;
        }
      },
    });
  }

  beforeBuildSchema() {
    logger.log('🔍 Setup:', {
      hasToGraphQL: typeof ObjectRef.prototype.toGraphQL === 'function',
      hasBuilder: !!schemaBuilder,
    });
  }
}

logger.log('🔧 Registering plugin');
SchemaBuilder.registerPlugin(pluginName, PothosTransformPlugin);
export default pluginName;

emroot avatar Mar 12 '25 20:03 emroot

Nice, glad you got something working!

hayes avatar Mar 12 '25 21:03 hayes