Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to implement Type Interface(/Union) ? #1210

Closed
1 of 3 tasks
flo-pereira opened this issue Dec 12, 2019 · 14 comments
Closed
1 of 3 tasks

How to implement Type Interface(/Union) ? #1210

flo-pereira opened this issue Dec 12, 2019 · 14 comments
Labels
👥 duplicate ❔ question 🔁 revisit-in-v5 Not happy with this behaviour, but changing it is a breaking change.

Comments

@flo-pereira
Copy link

Hi, I'm submitting a

  • bug report
  • feature request
  • question

PostGraphile version: 4.5.0

Hi have a table like this :

create table public.user (
  id uuid primary key default uuid_generate_v4(),
  name text
);

create table public.item (
  id uuid primary key default uuid_generate_v4(),
  description text
);

create table public.option (
  id uuid primary key default uuid_generate_v4(),
  name text,
  active boolean
);

create type public.task_step as enum (
  'task_one',
  'task_two'
);

create table public.todo (
  id uuid primary key default uuid_generate_v4(),
  user_id uuid references public.user(id),
  item_id uuid references public.item(id),
  option_id uuid references public.option(id),
  state public.task_step not null,
  created_at timestamp default now(),
  updated_at timestamp default now(),
);

create function public.current_task() returns public.todo as $$
  select todo.*
  from public.todo as todo
  where todo.user_id = current_user_id()
  order by todo.updated_at desc
  limit 1
$$ language sql stable;

I can make query like

currentTask {
  item {
    description
  }
   step
  updatedAt
}

And it work like it's supposed to be.

But I need another behaviour and I dont know how to do it.
I need the same currentTask function to return a kind of TaskInterface depending on todo.step which I could query like below:

currentTask {
  ... on TaskOne {
    ...TaskOneFragment
  }
  ... on TaskTwo {
    ...TaskOneFragment
  }
}

What is the best way to do this ?
Thanks.

@flo-pereira
Copy link
Author

flo-pereira commented Dec 13, 2019

I've try this

const { makeExtendSchemaPlugin, gql } = require('graphile-utils');

const addTaskInterface = makeExtendSchemaPlugin(
  ({ pgSql: sql, graphql: { getNamedType, ...a } }) => {
    return {
      typeDefs: gql`
        type TaskOne {
          id: ID!
          item: Item
          createdAt: Datetime
          updatedAt: Datetime
          step: TaskStep!
        }

        type TaskTwo {
          id: ID!
          option Option
          createdAt: Datetime
          updatedAt: Datetime
          step: TaskStep!
        }

        union Task = TaskOne | TaskTwo

        extend type Query {
          getCurrentTask: Task
        }
      `,
      resolvers: {
        Task: {
          resolveType(task) {
            return {
              task_one: getNamedType('TaskOne'),
              task_two: getNamedType('TaskTwo'),
            }[task.step];
          },
        },
        Query: {
          getCurrentTask: async (_query, args, { pgClient }, resolveInfo) => {
            const {
              rows: [task],
            } = await pgClient.query(`select * from public.current_task()`);

            if (!task.id) {
              return null;
            }

            // task = { id: 11c1caeb-d333-486c-b443-94d9d4daf87d, step: task_one, ...etc }

            const [
              row,
            ] = await resolveInfo.graphile.selectGraphQLResultFromTable(
              sql.fragment`public.current_task()`
            );

            /*
              console.log({ row }); => { row: {}} // there is a result but no value

              To be sure, I tried with
              const result = await resolveInfo.graphile.selectGraphQLResultFromTable(sql.fragment`public.task`);

              console.log({ result });
              { result: [ {}, {}, {}, {}, {} ] } // there is results but values are not populated
            */

            return row;
          },
        },
      },
    };
  }
);

module.exports = addTaskInterface;

As you can see in the comments, results are there but field aren't populate with the values 😥

@benjie
Copy link
Member

benjie commented Dec 13, 2019

We don't currently support PostgreSQL interfaces/unions because it doesn't currently work with our query planning infrastructure. You can track progress on this feature via:

#387

If you're interested in funding development of this feature please reach out.

@flo-pereira
Copy link
Author

flo-pereira commented Dec 13, 2019

ok, finaly did it this way

const addTaskInterface = makeExtendSchemaPlugin(
   return {
   /*...*/
      resolvers: {
        Task: {
          resolveType(task) {
            return {
              task_one: getNamedType('TaskOne'),
              task_two: getNamedType('TaskTwo'),
            }[task.step];
          },
        },
        Query: {
          getCurrentTask: async (_query, args, { pgClient }) => {
            const {
              rows: [task],
            } = await pgClient.query(`select * from public.current_task()`);

            if (!task.id) {
              return null;
            }

            return task;
          },
        },
        TaskOne: {
          item: {
            resolve: async (todo, args, context, resolveInfo) => {
              const [
                row,
              ] = await resolveInfo.graphile.selectGraphQLResultFromTable(
                sql.fragment`public.item`,
                (tableAlias, queryBuilder) => {
                  queryBuilder.where(
                    sql.fragment`${tableAlias}.id = ${sql.value(todo.item_id)}`
                  );
                }
              );

              return row;
            },
          },

little bit verbose, but it work.

@benjie
Copy link
Member

benjie commented Dec 15, 2019

That’s going to give you N+1 performance issues, but I’m glad you have a solution you’re happy with.

@ab-pm
Copy link
Contributor

ab-pm commented Dec 16, 2019

Hi @flo-pereira, thanks for the elaborate example! However I don't completely understand what the difference between the two task types is, they both seem to have exactly the same fields? Distinguishing the different kinds of tasks by the step enum should be simpler than distinguishing them by their GraphQL type (__typename).

@flo-pereira
Copy link
Author

Hi @ab-pm, you said

Distinguishing the different kinds of tasks by the step enum

Ok, but I dont think I have a way doing so without knowing which step I want.
And in my case, I query for a task without knowing its step

currentTask {
  ... on TaskOne {
    ...TaskOneFragment
  }
  ... on TaskTwo {
    ...TaskOneFragment
  }
}

@ab-pm
Copy link
Contributor

ab-pm commented Dec 16, 2019

@flo-pereira But you're using the same TaskOneFragment in both cases, and on both types you have the same fields (id, option, createdAt, updatedAt, and step)? Or are you really looking to query different fields, like in

currentTask {
  __typename
  ... on TaskOne {
    option
    createdAt
  }
  ... on TaskTwo {
    updatedAt
  }
}

Otherwise I would suggest to simply query all fields on both kinds of tasks

currentTask {
  option
  createdAt
  updatedAt
  step
}

and then distinguish them by their step.

@flo-pereira
Copy link
Author

flo-pereira commented Dec 17, 2019

that's a mistake, I'm looking for different fields

currentTask {
  ... on TaskOne {
    ...TaskOneFragment
  }
  ... on TaskTwo {
    ...TaskTwoFragment
  }
}

That's is just a simple code to illustrate what I'm looking for. Each Fragment have differents fields which need different joins in the database, so I dont want to query fields that are not needed.

@ab-pm
Copy link
Contributor

ab-pm commented Dec 18, 2019

Wow, yesterday I've written a custom plugin that does build a (specific) union type with complete lookahead support! It does require some ugly hacks, but also shows off how flexible Postgraphile is.

We've got a table that represents the Assignee union and references either a user or a usergroup to be the concrete object, so whenever there's a relation to an assignee row by its id, you get back either a User or a Usergroup (and never have to deal with assignee ids in the GraphQL)

CREATE TABLE public.assignee
(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    user_id integer UNIQUE,
    usergroup_id integer UNIQUE,
    CONSTRAINT assignee_fk_user_id FOREIGN KEY (user_id)  REFERENCES public."user" (id),
    CONSTRAINT assignee_fk_usergroup_id FOREIGN KEY (usergroup_id) REFERENCES public.usergroup (id),
    CONSTRAINT assignee_chck CHECK (num_nonnulls(user_id, usergroup_id) = 1)
)
COMMENT ON CONSTRAINT assignee_pkey ON public.assignee IS '@omit';
COMMENT ON CONSTRAINT assignee_user_id_key ON public.assignee IS '@omit';
COMMENT ON CONSTRAINT assignee_usergroup_id_key ON public.assignee IS '@omit';
COMMENT ON CONSTRAINT assignee_fk_user_id ON public.assignee IS '@omit';
COMMENT ON CONSTRAINT assignee_fk_usergroup_id ON public.assignee IS '@omit';

The assignee table is automatically filled with rows by a trigger when a user or usergroup is inserted.

import debugFactory from 'debug';
import { GraphileResolverContext, SchemaBuilder } from 'postgraphile';
import { GraphileUnionTypeConfig } from 'graphile-build';
import { SQL } from 'pg-sql2';
import { ResolveTree } from 'graphql-parse-resolve-info';

const debug = debugFactory('graphile-build-pg');

export default function(builder: SchemaBuilder): void {
	// Register a GraphQLUnionType for the public.assignee table instead of the usual GraphQLObjectType
	// that would normally be generated by the PgTables plugin
	builder.hook('init', (initObject, build) => {
		const {
			graphql,
			newWithHooks,
			getTypeByName,
		} = build;
		const assigneeTable = build.pgIntrospectionResultsByKind.class.find(table => table.name == 'assignee' && table.namespaceName == 'public');
		if (!assigneeTable)
			throw new Error('Could not find public.assignee');
		build.pgRegisterGqlTypeByTypeId(assigneeTable.type.id, () => {
			const scope = {
				__origin: 'Adding table type for public.assignee',
				pgIntrospection: assigneeTable,
				isPgRowType: assigneeTable.isSelectable,
				isPgCompositeType: !assigneeTable.isSelectable,
			};
			const tableSpec: GraphileUnionTypeConfig<any, GraphileResolverContext> = {
				name: 'Assignee',
				description: assigneeTable.description || null,
				types(_ctx) {
					return [
						getTypeByName('User'),
						getTypeByName('Usergroup')
					].map(type => {
						if (!type || !graphql.isObjectType(type)) throw new Error("could not find member type for union 'Assignee'");
						return type;
					});
				}
			};
			return newWithHooks(
				graphql.GraphQLUnionType,
				tableSpec,
				scope
			);
		}, false);
		return initObject;
	}, ['PgAssigneesUnion'], ['PgTables']); // PgTables must run after this

	// tweak fields that refer to the Assignee union type
	builder.hook('GraphQLObjectType:fields:field', (fieldConfig, build, context) => {
		const {
			graphql,
			pgQueryFromResolveData: queryFromResolveData,
			pgSql: sql,
		} = build;
		const {
			Self,
			scope: {
				fieldName,
				isPgFieldConnection,
				isPgFieldSimpleCollection,
				isPgForwardRelationField,
			},
			addArgDataGenerator,
			getDataFromParsedResolveInfoFragment,
		} = context;
		const unionType = graphql.getNamedType(fieldConfig.type);
		if (!(isPgFieldConnection || isPgFieldSimpleCollection || isPgForwardRelationField) || unionType.name != 'Assignee' || !graphql.isUnionType(unionType)) return fieldConfig;

		const memberTypes = unionType.getTypes(); // User and Usergroup, from the `tablespec` above
		const tablenameByType: { [typename: string]: SQL} = {User: sql.identifier('public', 'user'), Usergroup: sql.identifier('public', 'usergroup')};
		const foreignColumnsByType: { [typename: string]: [string, string]} = {User: ['user_id', 'id'], Usergroup: ['usergroup_id', 'id']};

		debug('Tweaking %s.%s to fetch concrete values for %s union', Self, fieldName, unionType);
		// We add an ArgDataGenerator because those are *always* called when the field is used, regardless of the subfields to be selected
		// usually they just add WHERE clauses to the query
		// The generator is called from the `getDataFromParsedResolveInfoFragment()` call inside the PgForwardRelationPlugin and similar ones that refer to the public.assignee table
		// To access the simplified query fragment (from which to lookahead into fields of concrete types), we need to make it part of the `args` object (see below)
		addArgDataGenerator(function evaluateHackyConcretionArgument(args, ReturnType) {
			// check whether we are called on the field that returns the union,
			// because this generator is also evaluated on the concrete type in the getDataFromParsedResolveInfoFragment call below
			if (ReturnType != unionType)
				return {}; // #fixme should return null once ts declaration of ArgDataGeneratorFunction allows it

			const concreteFragment = args.__concrete as ResolveTree;
			return {
				pgQuery(queryBuilder) {
					const unionTable = queryBuilder.getTableAlias();
					const subselects = memberTypes.map(concreteType => {
						// the `query` is constructed very similar to that in PgForwardRelationPlugin
						const [column, foreignColumn] = foreignColumnsByType[concreteType.name].map(x => sql.identifier(x));
						// getting lookahead data for concreteType
						const resolveData = getDataFromParsedResolveInfoFragment(
							concreteFragment,
							concreteType
						);
						const foreignTableAlias = sql.identifier(Symbol());
						const query = queryFromResolveData(
							tablenameByType[concreteType.name],
							foreignTableAlias,
							resolveData,
							{
								useAsterisk: false,
								asJson: true,
							},
							innerQueryBuilder => {
								innerQueryBuilder.parentQueryBuilder = queryBuilder;
								// FIXME subscriptions are ignored here, might need selectIdentifiers etc :-/

								// select the typename
								innerQueryBuilder.select(sql.literal(concreteType.name), '__typename');
								// add the join condition (just like in ForwardRelationPlugin)
								innerQueryBuilder.where(sql.fragment`${unionTable}.${column} = ${foreignTableAlias}.${foreignColumn}`);
							},
							queryBuilder.context,
							queryBuilder.rootValue
						);

						return sql.fragment` WHEN ${unionTable}.${column} IS NOT NULL THEN (${query})`;
					});
					queryBuilder.select(sql.fragment`CASE${sql.join(subselects)} END`, '__concrete');
					// unfortunately queryBuilder.selectIdentifiers() has already been called so we cannot just replace the entire selection clause
					// queryBuilder.fixedSelectExpression(sql.fragment`CASE${sql.join(subselects)} END`);
				}
			};
		});
		// access the `__concrete` field after resolving the field as usual (by alias etc), potentially nested in a list
		// FIXME lists nested in lists are not supported
		const getConcrete = graphql.isListType(graphql.getNullableType(fieldConfig.type))
			? (vals: any[]) => (console.log(vals), vals?.map(val => val?.__concrete))
			: (val: any) => val?.__concrete;
		return {
			...fieldConfig,
			resolve(...args) {
				return getConcrete(fieldConfig.resolve!(...args));
			}
		};
	});
	// hack into `simplifyParsedResolveInfoFragmentWithType` to make the original parsed fragment
	// available on the `args` object for all union types
	builder.hook('build', build => {
		const {
			simplifyParsedResolveInfoFragmentWithType: simplifyResolveInfo,
			graphql,
		} = build;
		return Object.assign(build, {
			simplifyParsedResolveInfoFragmentWithType(fragment: ResolveTree, type: import('graphql').GraphQLType) {
				const res = simplifyResolveInfo(fragment, type);
				if (graphql.isUnionType(type))
					res.args = {
						...res.args,
						__concrete: fragment
					};
				return res;
			}
		});
	});
}

@rrmckinley
Copy link

@ab-pm

Thanks for this example, but when I try it, I get the following error (with or without other plugins or configuration settings). I'm troubleshooting this now, but if you have insight, I would appreciate it, thanks

A serious error occurred when building the initial schema. Exiting because `retryOnInitFail` is not set. Error details:

Error: Could not find GraphQL connection type for table 'assignee'
    at reduce (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build-pg/src/plugins/PgAllRows.js:73:19)
    at Array.reduce (<anonymous>)
    at hook (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build-pg/src/plugins/PgAllRows.js:36:42)
    at SchemaBuilder.applyHooks (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/SchemaBuilder.js:394:20)
    at fields (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/makeNewBuild.js:668:40)
    at resolveThunk (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:438:40)
    at defineFieldMap (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:625:18)
    at GraphQLObjectType.getFields (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:579:27)
    at Object.newWithHooks (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/makeNewBuild.js:877:36)
    at QueryPlugin/GraphQLSchema/Query (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/plugins/QueryPlugin.js:31:25)

@jemikanegara
Copy link

@ab-pm

Thanks for this example, but when I try it, I get the following error (with or without other plugins or configuration settings). I'm troubleshooting this now, but if you have insight, I would appreciate it, thanks

A serious error occurred when building the initial schema. Exiting because `retryOnInitFail` is not set. Error details:

Error: Could not find GraphQL connection type for table 'assignee'
    at reduce (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build-pg/src/plugins/PgAllRows.js:73:19)
    at Array.reduce (<anonymous>)
    at hook (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build-pg/src/plugins/PgAllRows.js:36:42)
    at SchemaBuilder.applyHooks (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/SchemaBuilder.js:394:20)
    at fields (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/makeNewBuild.js:668:40)
    at resolveThunk (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:438:40)
    at defineFieldMap (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:625:18)
    at GraphQLObjectType.getFields (/Users/rich/Developer/graft-ai/api/node_modules/graphql/type/definition.js:579:27)
    at Object.newWithHooks (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/makeNewBuild.js:877:36)
    at QueryPlugin/GraphQLSchema/Query (/Users/rich/Developer/graft-ai/api/node_modules/graphile-build/src/plugins/QueryPlugin.js:31:25)

I got this error too

@BowlingX
Copy link
Contributor

In case someone stumbles across this question, something like this worked for me:

import { Plugin } from 'postgraphile'

export const UnionPlugin: Plugin = (builder, options) => {
  builder.hook(
    'build',
    (input, build, context) => {
      const original = input.newWithHooks
      build.addType(new build.graphql.GraphQLObjectType({
        name: 'SomeTypeA',
        fields: {
          url: {
            type: build.graphql.GraphQLString,
            async resolve(parent){
              // parent has all direct fields from the database result
              return 'https://postgraphile.org/'
            }
          }
        }
      }))
      // override the newWithHooks method (hacky)
      build.newWithHooks = function (...args: any[]) {
        const [type, config, scope] = args
        if (config?.name === 'YourTypeThatShouldBeAUnion') {
          // Our original base type
          original.apply(this, [type, { ...config, name: 'YourTypeThatShouldBeAUnionBase' }, scope])
          // And the union we want to create
          return original.apply(this, [
            build.graphql.GraphQLUnionType,
            {
              name: 'YourTypeThatShouldBeAUnion',
              types: [build.getTypeByName('YourTypeThatShouldBeAUnionBase'), build.getTypeByName('SomeTypeA')],
              resolveType(args: any) {
                // decide based on args what type to return (contains all fields of the row result)
                return 'SomeTypeA'
              },
            },
            scope,
          ])
        }
        return original.apply(this, args)
      }
      return build
    },
    [],
    ['PgTables']
  )
}

@benjie benjie added the 🔁 revisit-in-v5 Not happy with this behaviour, but changing it is a breaking change. label Apr 26, 2023
@benjie
Copy link
Member

benjie commented Apr 26, 2023

What an interesting approach!

@benjie
Copy link
Member

benjie commented Jul 21, 2023

Polymorphism is fully supported in PostGraphile V5, including a number of approaches (and support in the underlying engine to let you do it other ways too); read more: https://dev.to/graphile/intro-to-postgraphile-v5-part-5-polymorphism-40b4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
👥 duplicate ❔ question 🔁 revisit-in-v5 Not happy with this behaviour, but changing it is a breaking change.
Projects
None yet
Development

No branches or pull requests

6 participants