Skip to content

Conversation

@FionaBronwen
Copy link

@FionaBronwen FionaBronwen commented May 28, 2025

Summary

This PR:

  • Adds basic functionality for registering TSP Models and materializing GraphQL Types
  • Adds lazy loading for model properties to handle circular dependencies and forward references
  • Uses String as the default type for Scalars for now
  • Updates models in existing tests to include at least one property

Coming Soon

  • Make use of UsageFlags to generate GraphQLInputTypes and GraphQLOutputTypes
  • Properly apply TSP directives to models

@FionaBronwen FionaBronwen marked this pull request as ready for review May 28, 2025 19:41
@FionaBronwen FionaBronwen marked this pull request as draft May 29, 2025 16:48
@FionaBronwen FionaBronwen marked this pull request as ready for review May 29, 2025 19:40
}

addModel(tspModel: Model): void {
const modelName = tspModel.name;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can assume that tspModel.name is the "GraphQL name". There are a number of transformations that might be applied to the name.

Some of them (like use of the @friendlyName decorator, or visibility) will be resolved by the compiler (though I think we have to e.g. call getFriendlyName), but others we will be computing in the GraphQL emitter (like changing names to be GraphQL-friendly, adding Input suffixes, etc).

(forgive me, I may have asked this before about enums)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be wrong, but I don't think the TSPTypeContextRegistry is storing the GraphQL name. But, then I must ask what is the context registry storing and why?

private computeModelFields(tspModel: Model): GraphQLFieldConfigMap<any, any> {
const registry = this;

const fields: GraphQLFieldConfigMap<any, any> = {};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we come up with better types for this?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. I had to refactor the entire prototype code just to get proper types for this.


// Process each property of the model
for (const [propertyName, property] of tspModel.properties) {
const fieldConfig: any = {};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do better than the any type?

Comment on lines 121 to 132
// If the property type is a reference to another type, try to materialize it
if (property.type.kind === "Model") {
const referencedType = registry.materializeModel(property.type.name);
if (referencedType) {
fieldType = referencedType;
}
} else if (property.type.kind === "Enum") {
const referencedType = registry.materializeEnum(property.type.name);
if (referencedType) {
fieldType = referencedType;
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect we'll want a dedicated method that is able to do this sort of routing, i.e. it won't just be limited to handling model fields.

let fieldType: GraphQLOutputType = GraphQLString;

// If the property type is a reference to another type, try to materialize it
if (property.type.kind === "Model") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is JavaScript, and we have switch statements! 🎉

fields: this.computeModelFields(tspModel),
});

this.materializedGraphQLTypes.set(modelName, gqlObjectType);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect to see a similar pattern to addModel, but for materializedGraphQLTypes.

I am starting to think we want a class (a subclass of Map, perhaps) that can be used for TSPTypeContextRegistry and materializedGraphQLTypes, which maintains their internal consistency. e.g. it would:

  • have a standard "get or add" action (like addModel)
  • identify type conflicts (e.g. you're trying to add a model but the existing value is an enum)
  • do type checking (i.e. I state that I am trying to get a model, but the value it has is not a model)
  • has some kind of encapsulation of a "reset state" behavior
  • can potentially track additional metadata about the types that isn't exposed externally (e.g. how many times were they accessed / set)?

I would also search the TSP code for something similar that already exists.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I think the prototype does this badly by having multiple top-level maps. We could have something like:

/**
 * TypeSpec context for type mapping
 * @template T - The TypeSpec type
 */
interface TSPContext<T = any> {
  type: T;                    // The TypeSpec type
  usageFlag: UsageFlags;      // How the type is being used
  name?: string;              // Optional name override
  metadata?: Record<string, any>; // Optional additional metadata
}

/**
 * Base TypeMap for all GraphQL type mappings
 * @template T - The TypeSpec type
 * @template G - The GraphQL type
 */
abstract class TypeMap<T, G> {
  // Map of materialized GraphQL types
  protected materializedMap = new Map<string, G>();
  
  // Map of registration contexts
  protected registrationMap = new Map<string, TSPContext<T>>();
  
  /**
   * Register a TypeSpec type with context for later materialization
   * @param context - The TypeSpec context
   * @returns The name used for registration
   */
  register(context: TSPContext<T>): string {
    const name = this.getNameFromContext(context);
    this.registrationMap.set(name, context);
    return name;
  }
  
  /**
   * Get the materialized GraphQL type
   * @param name - The type name
   * @returns The materialized GraphQL type or undefined
   */
  get(name: string): G | undefined {
    // Return already materialized type if available
    if (this.materializedMap.has(name)) {
      return this.materializedMap.get(name);
    }
    
    // Attempt to materialize if registered
    const context = this.registrationMap.get(name);
    if (context) {
      const materializedType = this.materialize(context);
      if (materializedType) {
        this.materializedMap.set(name, materializedType);
        return materializedType;
      }
    }
    
    return undefined;
  }
  
  /**
   * Check if a type is registered
   */
  isRegistered(name: string): boolean {
    return this.registrationMap.has(name);
  }
  
  /**
   * Get a name from a context
   */
  protected abstract getNameFromContext(context: TSPContext<T>): string;
  
  /**
   * Materialize a type from a context
   */
  protected abstract materialize(context: TSPContext<T>): G | undefined;
}

/**
 * Model field map to store thunk field configurations
 */
class ModelFieldMap {
  private fieldMap = new Map<string, ThunkFieldConfig>();
  
  /**
   * Add a field with thunk configuration
   */
  addField(
    fieldName: string, 
    type: ThunkGraphQLType,
    isOptional: boolean, 
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    this.fieldMap.set(fieldName, {
      type,
      isOptional,
      isList,
      args
    });
  }
  
  /**
   * Get all field thunk configurations
   */
  getFieldThunks(): Map<string, ThunkFieldConfig> {
    return this.fieldMap;
  }
}

/**
 * TypeMap for GraphQL Object types (output types)
 */
class ObjectTypeMap extends TypeMap<Model, GraphQLObjectType> {
  // Maps for fields by model name
  private modelFieldMaps = new Map<string, ModelFieldMap>();
  
  // For handling interfaces
  private interfacesMap = new Map<string, GraphQLInterfaceType[]>();
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Model>): string {
    return context.name || context.type.name || '';
  }
  
  /**
   * Register a field for a model
   */
  registerField(
    modelName: string,
    fieldName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    if (!this.modelFieldMaps.has(modelName)) {
      this.modelFieldMaps.set(modelName, new ModelFieldMap());
    }
    
    this.modelFieldMaps.get(modelName)!.addField(
      fieldName,
      type,
      isOptional,
      isList,
      args
    );
  }
  
  /**
   * Add an interface to a model
   */
  addInterface(modelName: string, interfaceType: GraphQLInterfaceType): void {
    if (!this.interfacesMap.has(modelName)) {
      this.interfacesMap.set(modelName, []);
    }
    this.interfacesMap.get(modelName)!.push(interfaceType);
  }
  
  /**
   * Get interfaces for a model
   */
  getInterfaces(modelName: string): GraphQLInterfaceType[] {
    return this.interfacesMap.get(modelName) || [];
  }
  
  /**
   * Materialize a GraphQL object type
   */
  protected override materialize(context: TSPContext<Model>): GraphQLObjectType | undefined {
    const modelName = this.getNameFromContext(context);
    
    return new GraphQLObjectType({
      name: modelName,
      fields: () => this.materializeFields(modelName),
      interfaces: () => this.getInterfaces(modelName)
    });
  }
  
  /**
   * Materialize fields for a model
   */
  private materializeFields(modelName: string): GraphQLFieldConfigMap<any, any> {
    const fieldMap = this.modelFieldMaps.get(modelName);
    if (!fieldMap) {
      return {};
    }
    
    const result: GraphQLFieldConfigMap<any, any> = {};
    const fieldThunks = fieldMap.getFieldThunks();
    
    fieldThunks.forEach((config, fieldName) => {
      let fieldType = config.type() as GraphQLOutputType;
      
      if (fieldType instanceof GraphQLInputObjectType) {
        throw new Error(
          `Model "${modelName}" has a field "${fieldName}" that is an input type. It should be an output type.`
        );
      }
      
      if (!config.isOptional) {
        fieldType = new GraphQLNonNull(fieldType);
      }
      
      if (config.isList) {
        fieldType = new GraphQLNonNull(new GraphQLList(fieldType));
      }
      
      result[fieldName] = {
        type: fieldType,
        args: config.args ? config.args() : undefined
      };
    });
    
    return result;
  }
}

/**
 * TypeMap for GraphQL Input types
 */
class InputTypeMap extends TypeMap<Model, GraphQLInputObjectType> {
  // Maps for fields by model name
  private modelFieldMaps = new Map<string, ModelFieldMap>();
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Model>): string {
    return context.name || `${context.type.name || ''}Input`;
  }
  
  /**
   * Register a field for an input model
   */
  registerField(
    modelName: string,
    fieldName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean
  ): void {
    if (!this.modelFieldMaps.has(modelName)) {
      this.modelFieldMaps.set(modelName, new ModelFieldMap());
    }
    
    this.modelFieldMaps.get(modelName)!.addField(
      fieldName,
      type,
      isOptional,
      isList
    );
  }
  
  /**
   * Materialize a GraphQL input type
   */
  protected override materialize(context: TSPContext<Model>): GraphQLInputObjectType | undefined {
    const modelName = this.getNameFromContext(context);
    
    return new GraphQLInputObjectType({
      name: modelName,
      fields: () => this.materializeFields(modelName)
    });
  }
  
  /**
   * Materialize fields for an input model
   */
  private materializeFields(modelName: string): GraphQLInputFieldConfigMap {
    const fieldMap = this.modelFieldMaps.get(modelName);
    if (!fieldMap) {
      return {};
    }
    
    const result: GraphQLInputFieldConfigMap = {};
    const fieldThunks = fieldMap.getFieldThunks();
    
    fieldThunks.forEach((config, fieldName) => {
      let fieldType = config.type() as GraphQLInputType;
      
      if (!(fieldType instanceof GraphQLInputType)) {
        throw new Error(
          `Model "${modelName}" has a field "${fieldName}" that is not an input type.`
        );
      }
      
      if (!config.isOptional) {
        fieldType = new GraphQLNonNull(fieldType);
      }
      
      if (config.isList) {
        fieldType = new GraphQLNonNull(new GraphQLList(fieldType));
      }
      
      result[fieldName] = {
        type: fieldType
      };
    });
    
    return result;
  }
}

/**
 * TypeMap for GraphQL Enum types
 */
class EnumTypeMap extends TypeMap<Enum, GraphQLEnumType> {
  private sanitizeFn: (name: string) => string;
  
  constructor(sanitizeFn: (name: string) => string) {
    super();
    this.sanitizeFn = sanitizeFn;
  }
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Enum>): string {
    return context.name || context.type.name || '';
  }
  
  /**
   * Materialize a GraphQL enum type
   */
  protected override materialize(context: TSPContext<Enum>): GraphQLEnumType | undefined {
    const enumType = context.type;
    const name = this.getNameFromContext(context);
    
    return new GraphQLEnumType({
      name,
      values: Array.from(enumType.members.values()).reduce<{
        [key: string]: GraphQLEnumValueConfig;
      }>((acc, member) => {
        acc[this.sanitizeFn(member.name)] = {
          value: member.name,
        };
        return acc;
      }, {})
    });
  }
}
... and more ...

Then the registry itself can be made to handle/manage these maps.

export class GraphQLTypeRegistry {
  // Type name registry
  private modelTypeNames = new ModelTypeRegistry();
  
  // Type maps for different GraphQL types
  private objectTypes: ObjectTypeMap;
  private inputTypes: InputTypeMap;
  private interfaceTypes: InterfaceTypeMap;
  private enumTypes: EnumTypeMap;
  private unionTypes: UnionTypeMap;
  
  constructor() {
    // Initialize type maps with necessary dependencies
    this.objectTypes = new ObjectTypeMap();
    this.inputTypes = new InputTypeMap();
    this.interfaceTypes = new InterfaceTypeMap(this.objectTypes);
    this.enumTypes = new EnumTypeMap(this.sanitizeEnumMemberName.bind(this));
    this.unionTypes = new UnionTypeMap(this.objectTypes);
  }
  
  /**
   * Register a model with usage context
   */
  addModelUsage(model: Model, usageFlag: UsageFlags): void {
    const modelName = model.name;
    if (!modelName) return;
    
    // Register with the type name registry
    const graphqlTypeName = this.modelTypeNames.registerTypeName(modelName, usageFlag);
    
    // Create context for registration
    const context: TSPContext<Model> = {
      type: model,
      usageFlag,
      name: graphqlTypeName
    };
    
    // Register with the appropriate type map
    if (usageFlag === UsageFlags.Output) {
      this.objectTypes.register(context);
    } else if (usageFlag === UsageFlags.Input) {
      this.inputTypes.register(context);
    }
  }
  
  /**
   * Get all GraphQL type names for a model
   */
  getModelTypeNames(modelName: string): ModelTypeNames {
    return this.modelTypeNames.getModelTypeNames(modelName);
  }
  
  /**
   * Register a model property
   */
  addModelProperty(
    parentModelName: string,
    propName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    // Get all GraphQL type names for the model
    const typeNames = this.getModelTypeNames(parentModelName);
    
    // Add to appropriate type maps based on usage
    const outputTypeName = typeNames[UsageFlags.Output];
    if (outputTypeName) {
      this.objectTypes.registerField(
        outputTypeName,
        propName,
        type,
        isOptional,
        isList,
        args
      );
    }
    
    const inputTypeName = typeNames[UsageFlags.Input];
    if (inputTypeName) {
      this.inputTypes.registerField(
        inputTypeName,
        propName,
        type,
        isOptional,
        isList
      );
    }
  }
  
... and more ...

You can look at TSP for more examples/patterns on how to do this as well, but that would be the general idea.

Copy link
Author

@FionaBronwen FionaBronwen Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, I like that approach better! I made updates in this PR buts it's getting too lengthy for my taste. Going to break all of this into smaller PRs starting with #30. Thanks!

Comment on lines 67 to 73
this.registry.addModel(node);
},
exitEnum: (node: Enum) => {
this.registry.materializeEnum(node.name);
},
exitModel: (node: Model) => {
// Add logic to handle the exit of the model node
this.registry.materializeModel(node.name);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you describe (possibly in a code comment or in the commit message) why we want to add the model on visit, but materialize it on exit?

return gqlEnum;
}

private computeModelFields(tspModel: Model): GraphQLFieldConfigMap<any, any> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've seen elsewhere in the TypeSpec codebase (and it makes sense to me), we should use JavaScript's #private properties over TypeScript's (pseudo-)private properties.

Comment on lines +17 to +19
@test model TestModel {
name: string;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we adding fields to all these test models?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to fix a bunch of schema validation errors: error GraphQLSchemaValidationError: Type TestModel must define one or more fields.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. That is a real issue though, as TypeSpec has no problem with empty models.
So I think instead of changing the TSP schema, we need to handle empty TSP models in the GraphQL emitter.

@FionaBronwen FionaBronwen marked this pull request as draft May 30, 2025 17:29
return gqlEnum;
}

private computeModelFields(tspModel: Model): GraphQLFieldConfigMap<any, any> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be navigated from the model, instead this should be navigated from modelProperty on the navigateModel.

}

addModel(tspModel: Model): void {
const modelName = tspModel.name;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be wrong, but I don't think the TSPTypeContextRegistry is storing the GraphQL name. But, then I must ask what is the context registry storing and why?

private computeModelFields(tspModel: Model): GraphQLFieldConfigMap<any, any> {
const registry = this;

const fields: GraphQLFieldConfigMap<any, any> = {};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. I had to refactor the entire prototype code just to get proper types for this.


// If the property type is a reference to another type, try to materialize it
if (property.type.kind === "Model") {
const referencedType = registry.materializeModel(property.type.name);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are creating a recursion within an existing recursion (navigateProgram) by doing this. You want to use the navigateProgram's modelProperty to collect the fields and store them to be materialized later in exitModel

fields: this.computeModelFields(tspModel),
});

this.materializedGraphQLTypes.set(modelName, gqlObjectType);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I think the prototype does this badly by having multiple top-level maps. We could have something like:

/**
 * TypeSpec context for type mapping
 * @template T - The TypeSpec type
 */
interface TSPContext<T = any> {
  type: T;                    // The TypeSpec type
  usageFlag: UsageFlags;      // How the type is being used
  name?: string;              // Optional name override
  metadata?: Record<string, any>; // Optional additional metadata
}

/**
 * Base TypeMap for all GraphQL type mappings
 * @template T - The TypeSpec type
 * @template G - The GraphQL type
 */
abstract class TypeMap<T, G> {
  // Map of materialized GraphQL types
  protected materializedMap = new Map<string, G>();
  
  // Map of registration contexts
  protected registrationMap = new Map<string, TSPContext<T>>();
  
  /**
   * Register a TypeSpec type with context for later materialization
   * @param context - The TypeSpec context
   * @returns The name used for registration
   */
  register(context: TSPContext<T>): string {
    const name = this.getNameFromContext(context);
    this.registrationMap.set(name, context);
    return name;
  }
  
  /**
   * Get the materialized GraphQL type
   * @param name - The type name
   * @returns The materialized GraphQL type or undefined
   */
  get(name: string): G | undefined {
    // Return already materialized type if available
    if (this.materializedMap.has(name)) {
      return this.materializedMap.get(name);
    }
    
    // Attempt to materialize if registered
    const context = this.registrationMap.get(name);
    if (context) {
      const materializedType = this.materialize(context);
      if (materializedType) {
        this.materializedMap.set(name, materializedType);
        return materializedType;
      }
    }
    
    return undefined;
  }
  
  /**
   * Check if a type is registered
   */
  isRegistered(name: string): boolean {
    return this.registrationMap.has(name);
  }
  
  /**
   * Get a name from a context
   */
  protected abstract getNameFromContext(context: TSPContext<T>): string;
  
  /**
   * Materialize a type from a context
   */
  protected abstract materialize(context: TSPContext<T>): G | undefined;
}

/**
 * Model field map to store thunk field configurations
 */
class ModelFieldMap {
  private fieldMap = new Map<string, ThunkFieldConfig>();
  
  /**
   * Add a field with thunk configuration
   */
  addField(
    fieldName: string, 
    type: ThunkGraphQLType,
    isOptional: boolean, 
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    this.fieldMap.set(fieldName, {
      type,
      isOptional,
      isList,
      args
    });
  }
  
  /**
   * Get all field thunk configurations
   */
  getFieldThunks(): Map<string, ThunkFieldConfig> {
    return this.fieldMap;
  }
}

/**
 * TypeMap for GraphQL Object types (output types)
 */
class ObjectTypeMap extends TypeMap<Model, GraphQLObjectType> {
  // Maps for fields by model name
  private modelFieldMaps = new Map<string, ModelFieldMap>();
  
  // For handling interfaces
  private interfacesMap = new Map<string, GraphQLInterfaceType[]>();
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Model>): string {
    return context.name || context.type.name || '';
  }
  
  /**
   * Register a field for a model
   */
  registerField(
    modelName: string,
    fieldName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    if (!this.modelFieldMaps.has(modelName)) {
      this.modelFieldMaps.set(modelName, new ModelFieldMap());
    }
    
    this.modelFieldMaps.get(modelName)!.addField(
      fieldName,
      type,
      isOptional,
      isList,
      args
    );
  }
  
  /**
   * Add an interface to a model
   */
  addInterface(modelName: string, interfaceType: GraphQLInterfaceType): void {
    if (!this.interfacesMap.has(modelName)) {
      this.interfacesMap.set(modelName, []);
    }
    this.interfacesMap.get(modelName)!.push(interfaceType);
  }
  
  /**
   * Get interfaces for a model
   */
  getInterfaces(modelName: string): GraphQLInterfaceType[] {
    return this.interfacesMap.get(modelName) || [];
  }
  
  /**
   * Materialize a GraphQL object type
   */
  protected override materialize(context: TSPContext<Model>): GraphQLObjectType | undefined {
    const modelName = this.getNameFromContext(context);
    
    return new GraphQLObjectType({
      name: modelName,
      fields: () => this.materializeFields(modelName),
      interfaces: () => this.getInterfaces(modelName)
    });
  }
  
  /**
   * Materialize fields for a model
   */
  private materializeFields(modelName: string): GraphQLFieldConfigMap<any, any> {
    const fieldMap = this.modelFieldMaps.get(modelName);
    if (!fieldMap) {
      return {};
    }
    
    const result: GraphQLFieldConfigMap<any, any> = {};
    const fieldThunks = fieldMap.getFieldThunks();
    
    fieldThunks.forEach((config, fieldName) => {
      let fieldType = config.type() as GraphQLOutputType;
      
      if (fieldType instanceof GraphQLInputObjectType) {
        throw new Error(
          `Model "${modelName}" has a field "${fieldName}" that is an input type. It should be an output type.`
        );
      }
      
      if (!config.isOptional) {
        fieldType = new GraphQLNonNull(fieldType);
      }
      
      if (config.isList) {
        fieldType = new GraphQLNonNull(new GraphQLList(fieldType));
      }
      
      result[fieldName] = {
        type: fieldType,
        args: config.args ? config.args() : undefined
      };
    });
    
    return result;
  }
}

/**
 * TypeMap for GraphQL Input types
 */
class InputTypeMap extends TypeMap<Model, GraphQLInputObjectType> {
  // Maps for fields by model name
  private modelFieldMaps = new Map<string, ModelFieldMap>();
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Model>): string {
    return context.name || `${context.type.name || ''}Input`;
  }
  
  /**
   * Register a field for an input model
   */
  registerField(
    modelName: string,
    fieldName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean
  ): void {
    if (!this.modelFieldMaps.has(modelName)) {
      this.modelFieldMaps.set(modelName, new ModelFieldMap());
    }
    
    this.modelFieldMaps.get(modelName)!.addField(
      fieldName,
      type,
      isOptional,
      isList
    );
  }
  
  /**
   * Materialize a GraphQL input type
   */
  protected override materialize(context: TSPContext<Model>): GraphQLInputObjectType | undefined {
    const modelName = this.getNameFromContext(context);
    
    return new GraphQLInputObjectType({
      name: modelName,
      fields: () => this.materializeFields(modelName)
    });
  }
  
  /**
   * Materialize fields for an input model
   */
  private materializeFields(modelName: string): GraphQLInputFieldConfigMap {
    const fieldMap = this.modelFieldMaps.get(modelName);
    if (!fieldMap) {
      return {};
    }
    
    const result: GraphQLInputFieldConfigMap = {};
    const fieldThunks = fieldMap.getFieldThunks();
    
    fieldThunks.forEach((config, fieldName) => {
      let fieldType = config.type() as GraphQLInputType;
      
      if (!(fieldType instanceof GraphQLInputType)) {
        throw new Error(
          `Model "${modelName}" has a field "${fieldName}" that is not an input type.`
        );
      }
      
      if (!config.isOptional) {
        fieldType = new GraphQLNonNull(fieldType);
      }
      
      if (config.isList) {
        fieldType = new GraphQLNonNull(new GraphQLList(fieldType));
      }
      
      result[fieldName] = {
        type: fieldType
      };
    });
    
    return result;
  }
}

/**
 * TypeMap for GraphQL Enum types
 */
class EnumTypeMap extends TypeMap<Enum, GraphQLEnumType> {
  private sanitizeFn: (name: string) => string;
  
  constructor(sanitizeFn: (name: string) => string) {
    super();
    this.sanitizeFn = sanitizeFn;
  }
  
  /**
   * Get a name from a context
   */
  protected override getNameFromContext(context: TSPContext<Enum>): string {
    return context.name || context.type.name || '';
  }
  
  /**
   * Materialize a GraphQL enum type
   */
  protected override materialize(context: TSPContext<Enum>): GraphQLEnumType | undefined {
    const enumType = context.type;
    const name = this.getNameFromContext(context);
    
    return new GraphQLEnumType({
      name,
      values: Array.from(enumType.members.values()).reduce<{
        [key: string]: GraphQLEnumValueConfig;
      }>((acc, member) => {
        acc[this.sanitizeFn(member.name)] = {
          value: member.name,
        };
        return acc;
      }, {})
    });
  }
}
... and more ...

Then the registry itself can be made to handle/manage these maps.

export class GraphQLTypeRegistry {
  // Type name registry
  private modelTypeNames = new ModelTypeRegistry();
  
  // Type maps for different GraphQL types
  private objectTypes: ObjectTypeMap;
  private inputTypes: InputTypeMap;
  private interfaceTypes: InterfaceTypeMap;
  private enumTypes: EnumTypeMap;
  private unionTypes: UnionTypeMap;
  
  constructor() {
    // Initialize type maps with necessary dependencies
    this.objectTypes = new ObjectTypeMap();
    this.inputTypes = new InputTypeMap();
    this.interfaceTypes = new InterfaceTypeMap(this.objectTypes);
    this.enumTypes = new EnumTypeMap(this.sanitizeEnumMemberName.bind(this));
    this.unionTypes = new UnionTypeMap(this.objectTypes);
  }
  
  /**
   * Register a model with usage context
   */
  addModelUsage(model: Model, usageFlag: UsageFlags): void {
    const modelName = model.name;
    if (!modelName) return;
    
    // Register with the type name registry
    const graphqlTypeName = this.modelTypeNames.registerTypeName(modelName, usageFlag);
    
    // Create context for registration
    const context: TSPContext<Model> = {
      type: model,
      usageFlag,
      name: graphqlTypeName
    };
    
    // Register with the appropriate type map
    if (usageFlag === UsageFlags.Output) {
      this.objectTypes.register(context);
    } else if (usageFlag === UsageFlags.Input) {
      this.inputTypes.register(context);
    }
  }
  
  /**
   * Get all GraphQL type names for a model
   */
  getModelTypeNames(modelName: string): ModelTypeNames {
    return this.modelTypeNames.getModelTypeNames(modelName);
  }
  
  /**
   * Register a model property
   */
  addModelProperty(
    parentModelName: string,
    propName: string,
    type: ThunkGraphQLType,
    isOptional: boolean,
    isList: boolean,
    args?: ThunkGraphQLFieldConfigArgumentMap
  ): void {
    // Get all GraphQL type names for the model
    const typeNames = this.getModelTypeNames(parentModelName);
    
    // Add to appropriate type maps based on usage
    const outputTypeName = typeNames[UsageFlags.Output];
    if (outputTypeName) {
      this.objectTypes.registerField(
        outputTypeName,
        propName,
        type,
        isOptional,
        isList,
        args
      );
    }
    
    const inputTypeName = typeNames[UsageFlags.Input];
    if (inputTypeName) {
      this.inputTypes.registerField(
        inputTypeName,
        propName,
        type,
        isOptional,
        isList
      );
    }
  }
  
... and more ...

You can look at TSP for more examples/patterns on how to do this as well, but that would be the general idea.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants