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);
},
},
},
};
@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