Skip to content

Instantly share code, notes, and snippets.

@mlipscombe
Last active October 8, 2022 12:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mlipscombe/c4361949a5b344a3d44906fd6a5aad36 to your computer and use it in GitHub Desktop.
Save mlipscombe/c4361949a5b344a3d44906fd6a5aad36 to your computer and use it in GitHub Desktop.
full text search plugin for postgraphile
const tsquery = require('pg-tsquery');
const TSVECTOR_TYPE_ID = 3614;
export const PostGraphileTSVPlugin = (builder) => {
builder.hook('inflection', (inflection, build) => {
return build.extend(inflection, {
fullTextScalarTypeName() {
return 'FullText';
},
pgTsvRank(fieldName) {
return this.camelCase(`${fieldName}-rank`);
},
pgTsvOrderByColumnRankEnum(attr, ascending) {
const columnName = this._columnName(attr, {
skipRowId: true,
});
return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`);
},
});
});
builder.hook('build', (build) => {
const {
addConnectionFilterOperator,
addType,
getTypeByName,
connectionFilterOperators,
pgSql: sql,
pgRegisterGqlTypeByTypeId: registerGqlTypeByTypeId,
pgRegisterGqlInputTypeByTypeId: registerGqlInputTypeByTypeId,
graphql: {
GraphQLInputObjectType, GraphQLString, GraphQLScalarType,
},
inflection,
} = build;
if (!(addConnectionFilterOperator instanceof Function)) {
throw new Error('PostGraphileTSVPlugin requires PostGraphileConnectionFilterPlugin to be loaded before it.');
}
const scalarName = inflection.fullTextScalarTypeName();
const FullText = new GraphQLScalarType({
name: scalarName,
serialize(value) {
return value;
},
parseValue(value) {
return value;
},
parseLiteral(lit) {
return lit;
},
});
// addType(FullText);
registerGqlTypeByTypeId(TSVECTOR_TYPE_ID, () => FullText);
registerGqlInputTypeByTypeId(TSVECTOR_TYPE_ID, () => FullText);
addConnectionFilterOperator(
'matches',
'Performs a full text search on the field.',
() => GraphQLString,
(identifier, val, fieldName, queryBuilder) => {
const tsQueryString = tsquery(val);
queryBuilder.select(
sql.query`ts_rank(${identifier}, to_tsquery(${sql.value(tsQueryString)}))`,
`__${fieldName}Rank`,
);
return sql.query`${identifier} @@ to_tsquery(${sql.value(tsQueryString)})`;
},
{
allowedFieldTypes: [scalarName],
resolveWithRawInput: true,
},
);
const FullTextFilter = new GraphQLInputObjectType({
name: 'FullTextFilter',
description: 'A filter to be used against `FullText` fields.',
fields: () => {
const operator = connectionFilterOperators.matches;
return {
matches: {
description: operator.description,
type: operator.resolveType(getTypeByName(scalarName)),
},
};
},
}, {
isPgTSVFilterInputType: true,
});
addType(FullTextFilter);
return build;
});
builder.hook('GraphQLObjectType:fields', (fields, build, context) => {
const {
pgIntrospectionResultsByKind: introspectionResultsByKind,
pg2gql,
graphql: { GraphQLFloat },
pgColumnFilter,
inflection,
} = build;
const {
scope: { isPgRowType, isPgCompoundType, pgIntrospection: table },
fieldWithHooks,
} = context;
if (
!(isPgRowType || isPgCompoundType) ||
!table ||
table.kind !== 'class'
) {
return fields;
}
const tsvColumns = introspectionResultsByKind.attribute
.filter(attr => attr.classId === table.id)
.filter(attr => parseInt(attr.typeId, 10) === TSVECTOR_TYPE_ID);
if (tsvColumns.length === 0) {
return fields;
}
const tsvFields = tsvColumns
.filter(attr => pgColumnFilter(attr, build, context))
.reduce((memo, attr) => {
const fieldName = inflection.column(attr);
const rankFieldName = inflection.pgTsvRank(fieldName);
memo[rankFieldName] = fieldWithHooks(
rankFieldName,
{
description: 'The score ranking.',
type: GraphQLFloat,
resolve: (data, _args, _context) => pg2gql(data[`__${fieldName}Rank`], GraphQLFloat),
},
{},
);
return memo;
}, {});
return Object.assign({}, fields, tsvFields);
});
builder.hook('GraphQLEnumType:values', (values, build, context) => {
const {
extend,
pgSql: sql,
pgColumnFilter,
pgIntrospectionResultsByKind: introspectionResultsByKind,
inflection,
} = build;
const { scope: { isPgRowSortEnum, pgIntrospection: table } } = context;
if (!isPgRowSortEnum || !table || table.kind !== 'class') {
return values;
}
const tsvColumns = introspectionResultsByKind.attribute
.filter(attr => attr.classId === table.id)
.filter(attr => parseInt(attr.typeId, 10) === TSVECTOR_TYPE_ID);
if (tsvColumns.length === 0) {
return values;
}
return extend(
values,
tsvColumns
.filter(attr => pgColumnFilter(attr, build, context))
.reduce((memo, attr) => {
const fieldName = inflection.column(attr);
const ascFieldName = inflection.pgTsvOrderByColumnRankEnum(attr, true);
const descFieldName = inflection.pgTsvOrderByColumnRankEnum(attr, false);
const findExpr = ({ queryBuilder }) => {
const expr = queryBuilder.data.select.filter(obj => obj[1] === `__${fieldName}Rank`);
return expr.length ? expr.shift()[0] : sql.fragment`1`;
};
memo[ascFieldName] = {
value: {
alias: `${attr.name}_rank_asc`,
specs: [[findExpr, true]],
},
};
memo[descFieldName] = {
value: {
alias: `${attr.name}_rank_desc`,
specs: [[findExpr, false]],
},
};
return memo;
}, {}),
`Adding TSV rank columes for sorting on table '${table.name}'`,
);
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment