postgraphile-plugin-connection-filter
Version:
Advanced filtering of Connections in PostGraphile
405 lines (387 loc) • 14.4 kB
JavaScript
module.exports = function PgConnectionArgFilterPlugin(
builder,
{ pgInflection: inflection }
) {
builder.hook("init", (_, build) => {
const {
newWithHooks,
getTypeByName,
pgIntrospectionResultsByKind: introspectionResultsByKind,
pgGetGqlInputTypeByTypeId,
graphql: {
GraphQLInputObjectType,
GraphQLString,
GraphQLList,
GraphQLNonNull,
GraphQLScalarType,
GraphQLEnumType,
},
pgColumnFilter,
connectionFilterAllowedFieldTypes,
connectionFilterOperators,
connectionFilterComputedColumns,
} = build;
const getOrCreateFieldFilterTypeByFieldTypeName = fieldTypeName => {
const fieldFilterTypeName = `${fieldTypeName}Filter`;
if (!getTypeByName(fieldFilterTypeName)) {
newWithHooks(
GraphQLInputObjectType,
{
name: fieldFilterTypeName,
description: `A filter to be used against ${fieldTypeName} fields. All fields are combined with a logical ‘and.’`,
fields: ({ fieldWithHooks }) =>
Object.keys(connectionFilterOperators).reduce(
(memo, operatorName) => {
const operator = connectionFilterOperators[operatorName];
const allowedFieldTypes = operator.options.allowedFieldTypes;
if (
!allowedFieldTypes ||
allowedFieldTypes.includes(fieldTypeName)
) {
memo[operatorName] = fieldWithHooks(operatorName, {
description: operator.description,
type: operator.resolveType(fieldTypeName),
});
}
return memo;
},
{}
),
},
{
isPgConnectionFilterFilter: true,
}
);
}
return getTypeByName(fieldFilterTypeName);
};
const extendFilterFields = (memo, fieldName, fieldType, fieldWithHooks) => {
if (
!(
fieldType instanceof GraphQLScalarType ||
fieldType instanceof GraphQLEnumType
) ||
!fieldType.name
) {
return memo;
}
const fieldTypeName = fieldType.name;
// Check whether this field type is filterable
if (
connectionFilterAllowedFieldTypes &&
!connectionFilterAllowedFieldTypes.includes(fieldTypeName)
) {
return memo;
}
const fieldFilterType = getOrCreateFieldFilterTypeByFieldTypeName(
fieldTypeName
);
if (fieldFilterType != null) {
memo[fieldName] = fieldWithHooks(
fieldName,
{
description: `Filter by the object’s \`${fieldName}\` field.`,
type: fieldFilterType,
},
{
isPgConnectionFilterField: true,
}
);
}
return memo;
};
// Add *Filter type for each Connection type
introspectionResultsByKind.class
.filter(table => table.isSelectable)
.filter(table => !!table.namespace)
.forEach(table => {
const tableTypeName = inflection.tableType(
table.name,
table.namespace.name
);
newWithHooks(
GraphQLInputObjectType,
{
description: `A filter to be used against \`${tableTypeName}\` object types. All fields are combined with a logical ‘and.’`,
name: `${tableTypeName}Filter`,
fields: context => {
const { fieldWithHooks } = context;
// Attr fields
const attrFields = introspectionResultsByKind.attribute
.filter(attr => attr.classId === table.id)
.filter(attr => pgColumnFilter(attr, build, context))
.reduce((memo, attr) => {
const fieldName = inflection.column(
attr.name,
table.name,
table.namespace.name
);
const fieldType =
pgGetGqlInputTypeByTypeId(attr.typeId) || GraphQLString;
return extendFilterFields(
memo,
fieldName,
fieldType,
fieldWithHooks
);
}, {});
// Proc fields (computed columns)
const tableType = introspectionResultsByKind.type.filter(
type =>
type.type === "c" &&
type.namespaceId === table.namespaceId &&
type.classId === table.id
)[0];
if (!tableType) {
throw new Error("Could not determine the type for this table");
}
const procFields = connectionFilterComputedColumns
? introspectionResultsByKind.procedure
.filter(proc => proc.isStable)
.filter(proc => proc.namespaceId === table.namespaceId)
.filter(proc => proc.name.startsWith(`${table.name}_`))
.filter(proc => proc.argTypeIds.length > 0)
.filter(proc => proc.argTypeIds[0] === tableType.id)
.reduce((memo, proc) => {
const argTypes = proc.argTypeIds.map(
typeId => introspectionResultsByKind.typeById[typeId]
);
if (
argTypes
.slice(1)
.some(
type =>
type.type === "c" &&
type.class &&
type.class.isSelectable
)
) {
// Accepts two input tables? Skip.
return memo;
}
if (argTypes.length > 1) {
// Accepts arguments? Skip.
return memo;
}
const pseudoColumnName = proc.name.substr(
table.name.length + 1
);
const fieldName = inflection.column(
pseudoColumnName,
table.name,
table.namespace.name
);
const fieldType = pgGetGqlInputTypeByTypeId(
proc.returnTypeId
);
return extendFilterFields(
memo,
fieldName,
fieldType,
fieldWithHooks
);
}, {})
: {};
// Logical operator fields
const logicalOperatorFields = {
and: fieldWithHooks(
"and",
{
description: `Checks for all expressions in this list.`,
type: new GraphQLList(
new GraphQLNonNull(
getTypeByName(`${tableTypeName}Filter`)
)
),
},
{
isPgConnectionFilterOperatorLogical: true,
}
),
or: fieldWithHooks(
"or",
{
description: `Checks for any expressions in this list.`,
type: new GraphQLList(
new GraphQLNonNull(
getTypeByName(`${tableTypeName}Filter`)
)
),
},
{
isPgConnectionFilterOperatorLogical: true,
}
),
not: fieldWithHooks(
"not",
{
description: `Negates the expression.`,
type: getTypeByName(`${tableTypeName}Filter`),
},
{
isPgConnectionFilterOperatorLogical: true,
}
),
};
return Object.assign(
{},
attrFields,
procFields,
logicalOperatorFields
);
},
},
{
pgIntrospection: table,
isPgConnectionFilter: true,
}
);
});
return _;
});
builder.hook(
"GraphQLObjectType:fields:field:args",
(args, build, context) => {
const {
pgSql: sql,
gql2pg,
extend,
getTypeByName,
pgGetGqlTypeByTypeId,
pgIntrospectionResultsByKind: introspectionResultsByKind,
pgColumnFilter,
connectionFilterOperators,
} = build;
const {
scope: { isPgFieldConnection, pgFieldIntrospection: table },
addArgDataGenerator,
} = context;
if (!isPgFieldConnection || !table || table.kind !== "class") {
return args;
}
// Generate SQL where clause from filter argument
addArgDataGenerator(function connectionFilter({ filter }) {
return {
pgQuery: queryBuilder => {
const attrByFieldName = introspectionResultsByKind.attribute
.filter(attr => attr.classId === table.id)
.filter(attr => pgColumnFilter(attr, build, context))
.reduce((memo, attr) => {
const fieldName = inflection.column(
attr.name,
table.name,
table.namespace && table.namespace.name
);
memo[fieldName] = attr;
return memo;
}, {});
const procByFieldName = introspectionResultsByKind.procedure
.filter(proc => proc.isStable)
.filter(proc => proc.namespaceId === table.namespaceId)
.filter(proc => proc.name.startsWith(`${table.name}_`))
.reduce((memo, proc) => {
const pseudoColumnName = proc.name.substr(
table.name.length + 1
);
const fieldName = inflection.column(
pseudoColumnName,
table.name,
table.namespace.name
);
memo[fieldName] = proc;
return memo;
}, {});
function resolveWhereComparison(fieldName, operatorName, input) {
const operator = connectionFilterOperators[operatorName];
const inputResolver = operator.options.inputResolver;
const attr = attrByFieldName[fieldName];
if (attr != null) {
const identifier = sql.query`${queryBuilder.getTableAlias()}.${sql.identifier(
attr.name
)}`;
const val = Array.isArray(input)
? sql.query`(${sql.join(
input.map(
i =>
sql.query`${gql2pg(
(inputResolver && inputResolver(i)) || i,
attr.type
)}`
),
","
)})`
: sql.query`${gql2pg(
(inputResolver && inputResolver(input)) || input,
attr.type
)}`;
return operator.resolveWhereClause(identifier, val, input);
}
const proc = procByFieldName[fieldName];
if (proc != null) {
const procReturnType =
introspectionResultsByKind.typeById[proc.returnTypeId];
const identifier = sql.query`${sql.identifier(
proc.namespace.name
)}.${sql.identifier(
proc.name
)}(${queryBuilder.getTableAlias()})`;
const val = sql.query`${gql2pg(
(inputResolver && inputResolver(input)) || input,
procReturnType
)}`;
return operator.resolveWhereClause(identifier, val, input);
}
throw new Error(
`Unable to resolve where comparison for filter field '${fieldName}'`
);
}
function resolveWhereLogic(obj) {
return sql.query`(${sql.join(
Object.keys(obj).map(key => {
if (key === "or") {
return sql.query`(${sql.join(
obj[key].map(o => {
return resolveWhereLogic(o);
}),
") or ("
)})`;
} else if (key === "and") {
return sql.query`(${sql.join(
obj[key].map(o => {
return resolveWhereLogic(o);
}),
") and ("
)})`;
} else if (key === "not") {
return sql.query`NOT (${resolveWhereLogic(obj[key])})`;
} else {
return sql.query`(${sql.join(
Object.keys(obj[key]).map(k => {
return resolveWhereComparison(key, k, obj[key][k]);
}),
") and ("
)})`;
}
}),
") and ("
)})`;
}
if (filter != null) {
queryBuilder.where(resolveWhereLogic(filter));
}
},
};
});
// Add filter argument for each Connection
const tableTypeName = pgGetGqlTypeByTypeId(table.type.id).name;
const TableFilterType = getTypeByName(`${tableTypeName}Filter`);
return extend(args, {
filter: {
description:
"A filter to be used in determining which values should be returned by the collection.",
type: TableFilterType,
},
});
}
);
};