-
-
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); | |
}, | |
}, | |
}, | |
}; | |
You might need to go with a slightly different design for that 🤷♂️
@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);
}
}
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.
@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
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
Got it working thanks!
@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':
will look like
I guess the smart tag must be:
I assume the problem is how to set the proper scope in build.registerObjectType ? right?