Skip to content

Instantly share code, notes, and snippets.

@benjie
Last active September 1, 2023 07:50
Show Gist options
  • Save benjie/b9f3b6d46db0b6ae2524a6c9fd15fb9a to your computer and use it in GitHub Desktop.
Save benjie/b9f3b6d46db0b6ae2524a6c9fd15fb9a to your computer and use it in GitHub Desktop.
declare global {
namespace GraphileBuild {
interface PgCodecTags {
// This enables TypeScript autocomplete for our @group smart tag
group?: string | string[];
}
interface Inflection {
// Our inflector to pick the name of the grouped type, e.g. `User` table
// type, and `address` group might produce `UserAddress` grouped type name
groupedTypeName(details: {
codec: PgCodecWithAttributes;
group: string;
}): string;
// Determines the name of the field which exposes the groupedTypeName.
groupedFieldName(details: {
codec: PgCodecWithAttributes;
group: string;
}): string;
// Our inflector to pick the name of the attribute added to the group.
groupColumn(details: {
codec: PgCodecWithAttributes;
group: string;
attributeName: string;
}): string;
}
interface ScopeObject {
// Scope data so other plugins can hook this
pgAttributeGroup?: string;
}
}
}
const PgGroupedAttributesPlugin: GraphileConfig.Plugin = {
name: "PgGroupedAttributesPlugin",
version: "0.0.0",
inflection: {
add: {
groupedTypeName(options, { codec, group }) {
return this.upperCamelCase(`${this.tableType(codec)}-${group}`);
},
groupedFieldName(options, { codec, group }) {
return this.camelCase(group);
},
groupColumn(options, { codec, group, attributeName }) {
const remainderOfName = attributeName.substring(
group.length + "_".length,
);
return this.camelCase(remainderOfName);
},
},
},
schema: {
entityBehavior: {
pgCodecAttribute(behavior, [codec, attributeName], build) {
// const attribute = codec.attributes[attributeName];
// Get the @group smart tag from the codec (table/type) the attribute belongs to:
const groupsRaw = codec.extensions?.tags?.group;
if (!groupsRaw) return behavior;
// Could be that there's multiple groups, make sure we're dealing with an array:
const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
// See if this attribute belongs to a group
const group = groups.find((g) => attributeName.startsWith(`${g}_`));
if (!group) return behavior;
// It does belong to a group, so we're going to remove the "select"
// behavior so that it isn't added by default, instead we'll add it
// ourself.
return [behavior, "-select"];
},
},
hooks: {
// The init phase is the only phase in which we're allowed to register
// types. We need a type to contain our @group attributes.
init(_, build) {
for (const [codecName, codec] of Object.entries(
build.input.pgRegistry.pgCodecs,
)) {
if (!codec.attributes) continue;
const groupsRaw = codec.extensions?.tags?.group;
if (!groupsRaw) continue;
const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
for (const group of groups) {
const attributes = Object.entries(codec.attributes).filter(
([attributeName]) => attributeName.startsWith(`${group}_`),
);
if (attributes.length === 0) {
console.warn(
`Codec ${codec.name} uses @group smart tag to declare group '${group}', but no attributes with names starting '${group}_' were found.`,
);
continue;
}
const groupTypeName = build.inflection.groupedTypeName({
codec: codec as PgCodecWithAttributes,
group,
});
build.registerObjectType(
groupTypeName,
{ pgCodec: codec, pgAttributeGroup: group },
() => ({
fields: () => attributes.reduce(
(memo, [attributeName, attribute]) => {
const fieldName = build.inflection.groupColumn({
codec: codec as PgCodecWithAttributes,
group,
attributeName,
});
const resolveResult = build.pgResolveOutputType(
attribute.codec,
attribute.notNull || attribute.extensions?.tags?.notNull,
);
if (!resolveResult) {
return memo;
}
const [baseCodec, type] = resolveResult;
if (baseCodec.attributes) {
console.warn(
`PgGroupedAttributesPlugin currently doesn't support composite attributes`,
);
return memo;
}
memo[fieldName] = {
description: attribute.description,
type,
plan($record: PgSelectSingleStep) {
return $record.get(attributeName);
},
};
return memo;
},
Object.create(null) as Record<
string,
GrafastFieldConfig<any, any, any, any, any>
>,
),
}),
"Grouped attribute scope from PgGroupedAttributesPlugin",
);
}
}
return _;
},
// Finally we need to use the type we generated above
GraphQLObjectType_fields(fields, build, context) {
const {
scope: {
pgCodec,
isPgClassType,
pgPolymorphism,
pgPolymorphicSingleTableType,
},
} = context;
if (!isPgClassType || !pgCodec?.attributes) {
return fields;
}
const codec = pgCodec as PgCodecWithAttributes;
const groupsRaw = codec.extensions?.tags?.group;
if (!groupsRaw) return fields;
const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
return groups.reduce((fields, group) => {
return build.recoverable(fields, () => {
const fieldName = build.inflection.groupedFieldName({
codec,
group,
});
const typeName = build.inflection.groupedTypeName({ codec, group });
const type = build.getOutputTypeByName(typeName);
const attributes = Object.entries(codec.attributes).filter(
([attributeName]) => attributeName.startsWith(`${group}_`),
);
const someAttributeIsNonNullable = attributes.some(
([name, attr]) => attr.notNull,
);
fields[fieldName] = {
// TODO: description
type: build.nullableIf(someAttributeIsNonNullable, type),
plan($parent) {
// We still represent the same thing - essentially we're
// transparent from a planning perspective.
return $parent;
},
};
return fields;
});
}, fields);
},
},
},
};
@andreyobrezkov
Copy link

@benjie, I updated the plugin a bit. No more typescript issues.

https://gist.github.com/andreyobrezkov/b68011a7bad80170c1fd95ba464286bf

I tried to change the plugin so it would support deeper groups. f.e.
Table 'poc':

title
teaser__description
teaser__image__name

will look like

query MyQuery {
  poc(id: "1") {
    title
    teaser {
      description
      image{
        name
      }
    }
  }
}

I guess the smart tag must be:

comment on table app_public.poc is E'@group teaser\n @group teaser__image\n @group image';

I assume the problem is how to set the proper scope in build.registerObjectType ? right?

@benjie
Copy link
Author

benjie commented Jul 31, 2023

You might need to go with a slightly different design for that 🤷‍♂️

@andreyobrezkov
Copy link

@benjie
my colleague Achintha upgraded this plugin. It supports nested groups now.

/* eslint-disable @typescript-eslint/no-namespace */
import type { GrafastFieldConfig } from "postgraphile/grafast";
import type { PgSelectSingleStep, PgCodecWithAttributes } from "@dataplan/pg";
import "postgraphile";
import { Tree } from "../libs/tree";
declare global {
  namespace GraphileBuild {
    interface PgCodecTags {
      // This enables TypeScript autocomplete for our @group smart tag
      group?: string | string[];
    }
    interface Inflection {
      // Our inflector to pick the name of the grouped type, e.g. `User` table
      // type, and `address` group might produce `UserAddress` grouped type name
      groupedTypeName(details: {
        codec: PgCodecWithAttributes;
        group: string | boolean;
      }): string;

      // Determines the name of the field which exposes the groupedTypeName.
      groupedFieldName(details: {
        codec: PgCodecWithAttributes;
        group: string | boolean;
      }): string;

      // Our inflector to pick the name of the attribute added to the group.
      groupColumn(details: {
        codec: PgCodecWithAttributes;
        group: string | boolean;
        attributeName: string;
      }): string;
    }
    interface ScopeObject {
      // Scope data so other plugins can hook this
      pgAttributeGroup?: string | boolean;
    }
  }
}

const separator: string = "__";

export const PgGroupedAttributesPlugin: GraphileConfig.Plugin = {
  name: "PgGroupedAttributesPlugin",
  version: "0.0.1",
  inflection: {
    add: {
      groupedTypeName(options, { codec, group }) {
        return this.upperCamelCase(`${this.tableType(codec)}-${group}`);
      },
      groupedFieldName(options, { codec, group }) {
        return this.camelCase(group.toString());
      },
      groupColumn(options, { codec, group, attributeName }) {
        const remainderOfName = attributeName.substring(
          group.toString().length + separator.length,
        );
        return this.camelCase(remainderOfName);
      },
    },
  },

  schema: {
    entityBehavior: {
      pgCodecAttribute(behavior, [codec, attributeName], build) {
        // Get the @group smart tag from the codec (table/type) the attribute belongs to:
        const groupsRaw = codec.extensions?.tags?.group;
        if (!groupsRaw) return behavior;
        // Could be that there's multiple groups, make sure we're dealing with an array:
        const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
        // See if this attribute belongs to a group
        const group = groups.find((g) => attributeName.startsWith(`${g}__`));
        if (!group) return behavior;
        // It does belong to a group, so we're going to remove the "select"
        // behavior so that it isn't added by default, instead we'll add it
        // ourself.
        return [behavior, "-select"];
      },
    },
    hooks: {
      // The init phase is the only phase in which we're allowed to register
      // types. We need a type to contain our @group attributes.
      init(_, build) {
        for (const [codecName, codec] of Object.entries(
          build.input.pgRegistry.pgCodecs,
        )) {
          const pgCodec = codec as PgCodecWithAttributes;

          if (!pgCodec.attributes) continue;
          const groupsRaw = pgCodec.extensions?.tags?.group;
          if (!groupsRaw) continue;
          const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
          for (let group of groups) {
            group = group.toString().replace(/\s/g, ""); // remove whitespace
            const attributes = Object.entries(pgCodec.attributes).filter(
              ([attributeName]) =>
                attributeName.startsWith(`${group}${separator}`),
            );
            if (attributes.length === 0) {
              console.warn(
                `Codec ${pgCodec.name} uses @group smart tag to declare group '${group}', but no attributes with names starting '${group}${separator}' were found.`,
              );
              continue;
            }

            // Building an attribute tree for group
            const tree = new Tree(group, separator);
            attributes.forEach(([attributeName, attribute]) => {
              const path = attributeName.split(separator);
              const ancestors: string[] = [];

              const pathLength = path.length;
              for (let i = 0; i < pathLength - 1; i++) {
                path.pop();
                ancestors.push(path.join(separator));
              }

              // Inserting ancestors before, if they do not exists
              ancestors.reverse();
              ancestors.forEach((nodeName) => {
                tree.insert(nodeName);
              });

              // Adding attribute as a metadata
              tree.insert(attributeName, { attribute });
            });

            // Depth first traverse on the tree
            tree.depthTraverse(({ node }) => {
              // Register new object type for each non-leaf node
              if (!node.isLeaf) {
                const groupTypeName = build.inflection.groupedTypeName({
                  codec: codec as PgCodecWithAttributes,
                  group: node.path,
                });

                build.registerObjectType(
                  groupTypeName,
                  { pgCodec, pgAttributeGroup: node.path },
                  () => {
                    const fields = Object.create(null) as Record<
                      string,
                      GrafastFieldConfig<any, any, any, any, any>
                    >

                    // Visiting each child and add attributes to the objectType
                    node.children.forEach((child) => {
                      if (child.meta?.attribute) {
                        const attribute = child.meta.attribute;

                        let fieldName = build.inflection.groupColumn({
                          codec: pgCodec,
                          group: node.path,
                          attributeName: child.path,
                        });

                        const resolveResult = build.pgResolveOutputType(
                          attribute.codec,
                          attribute.notNull ||
                          attribute.extensions?.tags?.notNull,
                        );
                        if (!resolveResult) {
                          return;
                        }
                        const [baseCodec, type] = resolveResult;
                        if (baseCodec.attributes) {
                          console.warn(
                            `PgGroupedAttributesPlugin currently doesn't support composite attributes`,
                          );
                          return;
                        }

                        // If a non-leaf node also act as an attribute, to keep the value
                        // instead letting it replaced by the objectType
                        // we assign a new fieldName.
                        fieldName = child.isLeaf ? fieldName : `${fieldName}Value`;

                        fields[fieldName] = {
                          description: attribute.description,
                          type,
                          plan($record: PgSelectSingleStep) {
                            return $record.get(child.path);
                          },
                        };
                      }

                      if (!child.isLeaf) {
                        const fieldName = build.inflection.groupColumn({
                          codec: pgCodec,
                          group: node.path,
                          attributeName: child.path,
                        });

                        const groupTypeName = build.inflection.groupedTypeName({
                          codec: codec as PgCodecWithAttributes,
                          group: child.path,
                        });

                        const type = build.getOutputTypeByName(groupTypeName);

                        fields[fieldName] = {
                          description: "Test attr",
                          type,
                          plan($parent) {
                            return $parent;
                          },
                        };
                      }
                    });

                    console.log(fields);
                    return { fields };
                  },
                  "Grouped attribute scope from PgGroupedAttributesPlugin",
                );
              }
            });
          }
        }
        return _;
      },

      // Finally we need to use the type we generated above
      GraphQLObjectType_fields(fields, build, context) {
        const {
          scope: {
            pgCodec,
            isPgClassType,
            pgPolymorphism,
            pgPolymorphicSingleTableType,
          },
        } = context;
        if (!isPgClassType || !pgCodec?.attributes) {
          return fields;
        }
        const codec = pgCodec as PgCodecWithAttributes;

        const groupsRaw = codec.extensions?.tags?.group;
        if (!groupsRaw) return fields;
        const groups = Array.isArray(groupsRaw) ? groupsRaw : [groupsRaw];
        return groups.reduce((fields, group) => {
          return build.recoverable(fields, () => {
            const fieldName = build.inflection.groupedFieldName({
              codec,
              group,
            });
            const typeName = build.inflection.groupedTypeName({ codec, group });
            const type = build.getOutputTypeByName(typeName);
            const attributes = Object.entries(codec.attributes).filter(
              ([attributeName]) => attributeName.startsWith(`${group}${separator}`),
            );
            const someAttributeIsNonNullable = attributes.some(
              ([name, attr]) => attr.notNull,
            );
            fields[fieldName] = {
              // TODO: description
              type: build.nullableIf(someAttributeIsNonNullable, type),
              plan($parent) {
                // We still represent the same thing - essentially we're
                // transparent from a planning perspective.
                return $parent;
              },
            };
            return fields;
          });
        }, fields);
      },
    },
  },
};

tree.ts

export class Tree {
  private _root: TreeNode;

  constructor(
    private _value: string,
    public separator: string = "__",
    private _meta?: object,
  ) {
    this._root = new TreeNode(_value, _value, _meta);
  }

  insert(name: string, meta?: object): void {
    const nameList = name.split(this.separator);
    const toBeInsert = nameList.pop() as string;

    const parent = this.findNode(this._root, nameList);

    if (parent === null) {
      console.log("parent not found");
      return;
    }

    parent.addChild(new TreeNode(toBeInsert, name, meta));
  }

  depthTraverse(callback: (meta: { node: TreeNode }) => void): void {
    this.depthTraversePrivate(this._root, callback);
  }

  private depthTraversePrivate(
    root: TreeNode,
    callback: (meta: { node: TreeNode }) => void,
  ): void {
    const children = root.children;

    children.forEach((child) => {
      this.depthTraversePrivate(child, callback);
    });

    callback({ node: root });
  }

  private findNode(root: TreeNode, list: string[]): TreeNode | null {
    const newList = [...list];
    const value = newList.shift();

    if (root.value !== value) {
      return null;
    }

    if (newList.length === 0) {
      return root;
    }

    const children = root.children;

    for (const element of children) {
      const node = this.findNode(element, newList);
      if (node) {
        return node;
      }
    }

    return null;
  }
}

class TreeNode {
  private _children: TreeNode[] = [];

  constructor(
    private _value: string,
    private _path: string,
    private _meta?: Record<string, any>,
  ) {}

  get value(): string {
    return this._value;
  }

  get path(): string {
    return this._path;
  }

  get isLeaf(): boolean {
    return this._children.length === 0;
  }

  get children(): TreeNode[] {
    return this._children;
  }

  get meta(): Record<string, any> | undefined {
    return this._meta;
  }

  set meta(value: Record<string, any>) {
    this._meta = value;
  }

  appendMeta(key: string, value: any): Record<string, any> {
    if (!this._meta) {
      this._meta = {};
    }
    this._meta[key] = value;
    return this._meta;
  }

  addChild(node: TreeNode): void {
    const childIndex = this._children.findIndex((c) => c.value === node.value);
    if (childIndex >= 0) {
      return;
    }
    this._children.push(node);
  }
}

@benjie
Copy link
Author

benjie commented Aug 3, 2023

Awesome! Maybe you'd like to take over maintenance of this as a proper plugin - either hosted on your own GitHub or under https://github.com/graphile-contrib? It would be up to you how you maintain it, but I imagine you'd add some tests and a README, set a version number and publish it.

@achintha-weerasinghe
Copy link

@benjie After updating the packages to beta 7, started getting the following error from this plugin. And this is fired from the line 169 if you check the original version.

/node_modules/graphile-build/dist/makeNewBuild.js:224
                throw new Error(`Error in spec callback for ${currentTypeDetails.klass.name} '${currentTypeDetails.typeName}'; the callback made a call to \`build.getTypeByName(${JSON.stringify(typeName)})\` (directly or indirectly) - this is the wrong time for such a call \
                      ^

Error: Error in spec callback for GraphQLObjectType 'SmartCollectionStructuralMetadata'; the callback made a call to `build.getTypeByName("EnumNarrativeType")` (directly or indirectly) - this is the wrong time for such a call to occur since it can lead to circular dependence. To fix this, ensure that any calls to `getTypeByName` can only occur inside of the callbacks, such as `fields()`, `interfaces()`, `types()` or similar. Be sure to use the callback style for these configuration options (e.g. change `interfaces: [getTypeByName('Foo')]` to `interfaces: () => [getTypeByName('Foo')]`

Node.js v18.14.1

@benjie
Copy link
Author

benjie commented Sep 1, 2023

The fields definition https://gist.github.com/benjie/b9f3b6d46db0b6ae2524a6c9fd15fb9a#file-pggroupedattributesplugin-ts-L102 should use the callback form instead: fields: () => ({…}); it stops it running too early

@achintha-weerasinghe
Copy link

Got it working thanks!

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