From 2cf36b9dc4f3c6693bc48ef77e8ef3fb4941260e Mon Sep 17 00:00:00 2001 From: Scott Twiname Date: Tue, 5 Mar 2024 09:41:41 +1300 Subject: [PATCH] Implement full text search (#2280) * Implement graphql directive to add full text search to db * Tidy up * update create column query Co-authored-by: Scott Twiname * Add plugin to sanitise fulltext search query * Update changelogs * Fix typos * Fix tests --------- Co-authored-by: Ben <89335033+bz888@users.noreply.github.com> --- packages/node-core/CHANGELOG.md | 2 + .../SchemaMigration.service.ts | 8 ++ .../db/migration-service/migration-helpers.ts | 42 +++++++- .../src/db/migration-service/migration.ts | 40 ++++++- packages/node-core/src/db/sync-helper.ts | 100 +++++++++++++++--- packages/query/CHANGELOG.md | 4 +- packages/query/package.json | 1 + .../src/graphql/plugins/PgSearchPlugin.ts | 31 ++++++ .../src/graphql/plugins/historical/utils.ts | 2 +- packages/query/src/graphql/plugins/index.ts | 2 + packages/utils/CHANGELOG.md | 2 + packages/utils/src/graphql/constant.ts | 1 + packages/utils/src/graphql/entities.ts | 33 ++++++ packages/utils/src/graphql/graphql.spec.ts | 46 ++++++++ .../utils/src/graphql/schema/directives.ts | 1 + packages/utils/src/graphql/types.ts | 8 ++ yarn.lock | 8 ++ 17 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 packages/query/src/graphql/plugins/PgSearchPlugin.ts diff --git a/packages/node-core/CHANGELOG.md b/packages/node-core/CHANGELOG.md index 147acebba7..e4bc15960c 100644 --- a/packages/node-core/CHANGELOG.md +++ b/packages/node-core/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Support for Full Text Search (#2280) ## [7.3.1] - 2024-02-29 ### Removed diff --git a/packages/node-core/src/db/migration-service/SchemaMigration.service.ts b/packages/node-core/src/db/migration-service/SchemaMigration.service.ts index 395eea2c26..6328136332 100644 --- a/packages/node-core/src/db/migration-service/SchemaMigration.service.ts +++ b/packages/node-core/src/db/migration-service/SchemaMigration.service.ts @@ -164,6 +164,14 @@ export class SchemaMigrationService { for (const index of modelValue.addedIndexes) { migrationAction.createIndex(modelValue.model, index); } + + if (modelValue.removedFullText) { + migrationAction.dropFullText(modelValue.model); + } + + if (modelValue.addedFullText) { + migrationAction.createFullText(modelValue.model); + } } for (const relationModel of addedRelations) { diff --git a/packages/node-core/src/db/migration-service/migration-helpers.ts b/packages/node-core/src/db/migration-service/migration-helpers.ts index c39e176437..4b659c20a7 100644 --- a/packages/node-core/src/db/migration-service/migration-helpers.ts +++ b/packages/node-core/src/db/migration-service/migration-helpers.ts @@ -5,6 +5,7 @@ import { GraphQLEntityField, GraphQLEntityIndex, GraphQLEnumsType, + GraphQLFullTextType, GraphQLModelsType, GraphQLRelationsType, } from '@subql/utils'; @@ -19,6 +20,9 @@ export type ModifiedModels = Record< addedIndexes: GraphQLEntityIndex[]; removedIndexes: GraphQLEntityIndex[]; + + addedFullText?: GraphQLFullTextType; + removedFullText?: GraphQLFullTextType; } >; @@ -36,14 +40,25 @@ export interface SchemaChangesType { modifiedEnums: GraphQLEnumsType[]; } -export function indexesEqual(index1: GraphQLEntityIndex, index2: GraphQLEntityIndex): boolean { +/** + * Checks that 2 string arrays are the same independent of order + * */ +function arrayUnorderedMatch(a?: string[], b?: string[]): boolean { + return a?.sort().join(',') === b?.sort().join(','); +} + +function indexesEqual(index1: GraphQLEntityIndex, index2: GraphQLEntityIndex): boolean { return ( - index1.fields.join(',') === index2.fields.join(',') && + arrayUnorderedMatch(index1.fields, index2.fields) && index1.unique === index2.unique && index1.using === index2.using ); } +function fullTextEqual(a?: GraphQLFullTextType, b?: GraphQLFullTextType): boolean { + return arrayUnorderedMatch(a?.fields, b?.fields) && a?.language === b?.language; +} + export function hasChanged(changes: SchemaChangesType): boolean { return Object.values(changes).some((change) => Array.isArray(change) ? change.length > 0 : Object.keys(change).length > 0 @@ -96,7 +111,7 @@ export function compareRelations( }); } -export function fieldsAreEqual(field1: GraphQLEntityField, field2: GraphQLEntityField): boolean { +function fieldsAreEqual(field1: GraphQLEntityField, field2: GraphQLEntityField): boolean { return ( field1.name === field2.name && field1.type === field2.type && @@ -134,13 +149,25 @@ export function compareModels( const addedIndexes = model.indexes.filter((index) => !currentModel.indexes.some((i) => indexesEqual(i, index))); const removedIndexes = currentModel.indexes.filter((index) => !model.indexes.some((i) => indexesEqual(i, index))); - if (addedFields.length || removedFields.length || addedIndexes.length || removedIndexes.length) { + const addedFullText = !fullTextEqual(model.fullText, currentModel.fullText) ? model.fullText : undefined; + const removedFullText = !fullTextEqual(model.fullText, currentModel.fullText) ? currentModel.fullText : undefined; + + if ( + addedFields.length || + removedFields.length || + addedIndexes.length || + removedIndexes.length || + addedFullText || + removedFullText + ) { changes.modifiedModels[model.name] = { model, addedFields, removedFields, addedIndexes, removedIndexes, + addedFullText, + removedFullText, }; } } @@ -195,6 +222,13 @@ export function schemaChangesLoggerMessage(schemaChanges: SchemaChangesType): st if (changes.removedIndexes.length) { logMessage += `\tRemoved Indexes: ${formatIndexes(changes.removedIndexes)}\n`; } + + if (changes.addedFullText) { + logMessage += `\tAdded FullText: ${changes.addedFullText.fields.join(', ')}\n`; + } + if (changes.removedFullText) { + logMessage += `\tRemoved FullText\n`; + } }); if (schemaChanges.addedEnums.length) { logMessage += `Added Enums: ${formatEnums(schemaChanges.addedEnums)}\n`; diff --git a/packages/node-core/src/db/migration-service/migration.ts b/packages/node-core/src/db/migration-service/migration.ts index 029dad3c21..8567f52001 100644 --- a/packages/node-core/src/db/migration-service/migration.ts +++ b/packages/node-core/src/db/migration-service/migration.ts @@ -1,11 +1,13 @@ // Copyright 2020-2024 SubQuery Pte Ltd authors & contributors // SPDX-License-Identifier: GPL-3.0 +import assert from 'node:assert'; import {SUPPORT_DB} from '@subql/common'; import { GraphQLEntityField, GraphQLEntityIndex, GraphQLEnumsType, + GraphQLFullTextType, GraphQLModelsType, GraphQLRelationsType, hashName, @@ -190,6 +192,10 @@ export class Migration { ); } + if (model.fullText) { + this.createFullText(model); + } + if (this.useSubscription) { const triggerName = hashName(this.schemaName, 'notify_trigger', sequelizeModel.tableName); const notifyTriggers = await syncHelper.getTriggers(this.sequelize, triggerName); @@ -305,7 +311,7 @@ export class Migration { singleForeignFieldName: relation.fieldName, }); this.extraQueries.push( - syncHelper.commentConstraintQuery(`"${this.schemaName}"."${rel.target.tableName}"`, fkConstraint, tags), + syncHelper.commentConstraintQuery(this.schemaName, rel.target.tableName, fkConstraint, tags), syncHelper.createUniqueIndexQuery(this.schemaName, relatedModel.tableName, relation.foreignKey) ); break; @@ -320,7 +326,7 @@ export class Migration { foreignFieldName: relation.fieldName, }); this.extraQueries.push( - syncHelper.commentConstraintQuery(`"${this.schemaName}"."${rel.target.tableName}"`, fkConstraint, tags) + syncHelper.commentConstraintQuery(this.schemaName, rel.target.tableName, fkConstraint, tags) ); break; } @@ -344,7 +350,7 @@ export class Migration { const comment = Array.from(keys.values()) .map((tags) => syncHelper.smartTags(tags, '|')) .join('\n'); - const query = syncHelper.commentTableQuery(`"${this.schemaName}"."${tableName}"`, comment); + const query = syncHelper.commentTableQuery(this.schemaName, tableName, comment); this.extraQueries.push(query); }); } @@ -425,6 +431,34 @@ export class Migration { }); } + createFullText(model: GraphQLModelsType): void { + assert(model.fullText, `Expected fullText to exist on model ${model.name}`); + + const table = modelToTableName(model.name); + + const queries = [ + syncHelper.createTsVectorColumnQuery(this.schemaName, table, model.fullText.fields, model.fullText.language), + syncHelper.createTsVectorCommentQuery(this.schemaName, table), + syncHelper.createTsVectorIndexQuery(this.schemaName, table), + syncHelper.createSearchFunctionQuery(this.schemaName, table), + syncHelper.commentSearchFunctionQuery(this.schemaName, table), + ]; + + this.mainQueries.push(...queries); + } + + dropFullText(model: GraphQLModelsType): void { + const table = modelToTableName(model.name); + + const queries = [ + syncHelper.dropSearchFunctionQuery(this.schemaName, table), + syncHelper.dropTsVectorIndexQuery(this.schemaName, table), + syncHelper.dropTsVectorColumnQuery(this.schemaName, table), + ]; + + this.mainQueries.push(...queries); + } + // Sequelize model will generate follow query to create hash indexes // Example SQL: CREATE INDEX "accounts_person_id" ON "polkadot-starter"."accounts" USING hash ("person_id") // This will be rejected from cockroach db due to syntax error diff --git a/packages/node-core/src/db/sync-helper.ts b/packages/node-core/src/db/sync-helper.ts index 18f5431f41..0564fd8cdd 100644 --- a/packages/node-core/src/db/sync-helper.ts +++ b/packages/node-core/src/db/sync-helper.ts @@ -68,16 +68,28 @@ export function getUniqConstraint(tableName: string, field: string): string { return [tableName, field, 'uindex'].map(underscored).join('_'); } -export function commentConstraintQuery(table: string, constraint: string, comment: string): string { - return `COMMENT ON CONSTRAINT ${constraint} ON ${table} IS E'${comment}'`; +function escapedName(...args: string[]): string { + return args.map((a) => `"${a}"`).join('.'); } -export function commentTableQuery(column: string, comment: string): string { - return `COMMENT ON TABLE ${column} IS E'${comment}'`; +function commentOn(type: 'CONSTRAINT' | 'TABLE' | 'COLUMN' | 'FUNCTION', entity: string, comment: string): string { + return `COMMENT ON ${type} ${entity} IS E'${comment}'`; +} + +export function commentConstraintQuery(schema: string, table: string, constraint: string, comment: string): string { + return commentOn('CONSTRAINT', escapedName(schema, table), comment); +} + +export function commentTableQuery(schema: string, table: string, comment: string): string { + return commentOn('TABLE', escapedName(schema, table), comment); } export function commentColumnQuery(schema: string, table: string, column: string, comment: string): string { - return `COMMENT ON COLUMN "${schema}".${table}.${column} IS '${comment}';`; + return commentOn('COLUMN', escapedName(schema, table, column), comment); +} + +export function commentOnFunction(schema: string, functionName: string, comment: string): string { + return commentOn('FUNCTION', escapedName(schema, functionName), comment); } // This is used when historical is disabled so that we can perform bulk updates @@ -195,14 +207,22 @@ END$$; `; } -export function dropNotifyTrigger(schema: string, table: string): string { - const triggerName = hashName(schema, 'notify_trigger', table); +function dropTrigger(schema: string, table: string, triggerName: string): string { return `DROP TRIGGER IF EXISTS "${triggerName}" ON "${schema}"."${table}";`; } +export function dropNotifyTrigger(schema: string, table: string): string { + const triggerName = hashName(schema, 'notify_trigger', table); + return dropTrigger(schema, table, triggerName); +} + +function dropFunction(schema: string, functionName: string): string { + return `DROP FUNCTION IF EXISTS "${schema}"."${functionName}";`; +} + export function dropNotifyFunction(schema: string): string { - return `DROP FUNCTION IF EXISTS "${schema}".send_notification()`; + return dropFunction(schema, 'send_notification'); } // Hot schema reload, _metadata table @@ -215,7 +235,7 @@ export function createSchemaTrigger(schema: string, metadataTableName: string): ON "${schema}"."${metadataTableName}" FOR EACH ROW WHEN ( new.key = 'schemaMigrationCount') - EXECUTE FUNCTION "${schema}".schema_notification()`; + EXECUTE FUNCTION "${schema}".schema_notification();`; } export function createSchemaTriggerFunction(schema: string): string { @@ -246,7 +266,7 @@ const DEFAULT_SQL_EXE_BATCH = 2000; * Improve SQL which could potentially increase DB IO significantly, * this executes it by batch size, and in ASC id order **/ -export const sqlIterator = (tableName: string, sql: string, batch: number = DEFAULT_SQL_EXE_BATCH) => { +export const sqlIterator = (tableName: string, sql: string, batch: number = DEFAULT_SQL_EXE_BATCH): string => { return ` DO $$ DECLARE @@ -367,6 +387,8 @@ export function generateCreateTableQuery( Object.keys(attributes).forEach((key) => { const attr = attributes[key]; + assert(attr.field, 'Expected field to be set on attribute'); + if (timestampKeys.find((k) => k === attr.field)) { attr.type = 'timestamp with time zone'; } @@ -375,7 +397,7 @@ export function generateCreateTableQuery( columnDefinitions.push(columnDefinition); if (attr.comment) { - comments.push(`COMMENT ON COLUMN "${schema}"."${tableName}"."${attr.field}" IS '${attr.comment}';`); + comments.push(commentColumnQuery(schema, tableName, attr.field, attr.comment)); } if (attr.primaryKey) { primaryKeyColumns.push(`"${attr.field}"`); @@ -443,7 +465,7 @@ export function sortModels(relations: GraphQLRelationsType[], models: GraphQLMod export function validateNotifyTriggers(triggerName: string, triggers: NotifyTriggerPayload[]): void { if (triggers.length !== NotifyTriggerManipulationType.length) { throw new Error( - `Found ${triggers.length} ${triggerName} triggers, expected ${NotifyTriggerManipulationType.length} triggers ` + `Found ${triggers.length} ${triggerName} triggers, expected ${NotifyTriggerManipulationType.length} triggers` ); } triggers.map((t) => { @@ -508,7 +530,7 @@ export async function getExistingEnums(schema: string, sequelize: Sequelize): Pr // enums export const dropEnumQuery = (enumTypeValue: string, schema: string): string => - `DROP TYPE IF EXISTS "${schema}"."${enumTypeValue}"`; + `DROP TYPE IF EXISTS "${schema}"."${enumTypeValue}";`; export const commentOnEnumQuery = (type: string, comment: string): string => `COMMENT ON TYPE ${type} IS E${comment};`; export const createEnumQuery = (type: string, enumValues: string): string => `CREATE TYPE ${type} AS ENUM (${enumValues});`; @@ -516,6 +538,7 @@ export const createEnumQuery = (type: string, enumValues: string): string => // relations export const dropRelationQuery = (schemaName: string, tableName: string, fkConstraint: string): string => `ALTER TABLE "${schemaName}"."${tableName}" DROP CONSTRAINT IF EXISTS ${fkConstraint};`; + export function generateForeignKeyQuery(attribute: ModelAttributeColumnOptions, tableName: string): string | void { const references = attribute?.references as ModelAttributeColumnReferencesOptions; if (!references) { @@ -561,7 +584,7 @@ export const createIndexQuery = (indexOptions: IndexesOptions, tableName: string `CREATE ${indexOptions.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS "${indexOptions.name}" ` + `ON "${schema}"."${tableName}" ` + `${indexOptions.using ? `USING ${indexOptions.using} ` : ''}` + - `(${indexOptions.fields.join(', ')})` + `(${indexOptions.fields.join(', ')});` ); }; @@ -569,5 +592,50 @@ export const createIndexQuery = (indexOptions: IndexesOptions, tableName: string export const dropColumnQuery = (schema: string, columnName: string, tableName: string): string => `ALTER TABLE "${schema}"."${modelToTableName(tableName)}" DROP COLUMN IF EXISTS ${columnName};`; -export const createColumnQuery = (schema: string, tableName: string, columnName: string, attributes: string): string => - `ALTER TABLE IF EXISTS "${schema}"."${tableName}" ADD COLUMN IF NOT EXISTS "${columnName}" ${attributes};`; +export const createColumnQuery = (schema: string, table: string, columnName: string, attributes: string): string => + `ALTER TABLE ${escapedName(schema, table)} ADD COLUMN IF NOT EXISTS "${columnName}" ${attributes};`; + +// fullTextSearch +const TS_VECTOR_COL = '_tsv'; + +export const dropTsVectorColumnQuery = (schema: string, table: string): string => + dropColumnQuery(schema, table, TS_VECTOR_COL); +export const createTsVectorColumnQuery = ( + schema: string, + table: string, + fields: string[], + language = 'english' +): string => { + const generated = `GENERATED ALWAYS as (to_tsvector('pg_catalog.${language}', ${fields + .map((field) => `coalesce(${escapedName(field)}, '')`) + .join(` || ' ' || `)})) STORED`; + return createColumnQuery(schema, table, TS_VECTOR_COL, `tsvector ${generated}`); +}; + +export const createTsVectorCommentQuery = (schema: string, table: string): string => + commentColumnQuery(schema, table, TS_VECTOR_COL, '@omit all'); + +const tsVectorIndexName = (schema: string, table: string) => hashName(schema, 'fulltext_idx', table); +export const dropTsVectorIndexQuery = (schema: string, table: string): string => + dropIndexQuery(schema, tsVectorIndexName(schema, table)); + +export const createTsVectorIndexQuery = (schema: string, table: string): string => + createIndexQuery({using: 'gist', fields: [TS_VECTOR_COL], name: tsVectorIndexName(schema, table)}, table, schema); + +const searchFunctionName = (schema: string, table: string): string => hashName(schema, 'search', table); +export const dropSearchFunctionQuery = (schema: string, table: string): string => + dropFunction(schema, searchFunctionName(schema, table)); +export const createSearchFunctionQuery = (schema: string, table: string): string => { + const functionName = searchFunctionName(schema, table); + return ` +create or replace function ${escapedName(schema, functionName)}(search text) + returns setof ${escapedName(schema, table)} as $$ + select * + from ${escapedName(schema, table)} as "table" + where "table"."${TS_VECTOR_COL}" @@ to_tsquery(search) + $$ language sql stable; + `; +}; + +export const commentSearchFunctionQuery = (schema: string, table: string): string => + commentOnFunction(schema, searchFunctionName(schema, table), `@name search_${table}`); diff --git a/packages/query/CHANGELOG.md b/packages/query/CHANGELOG.md index 66db9a329c..374cc3c3a3 100644 --- a/packages/query/CHANGELOG.md +++ b/packages/query/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Fulltext search plugin to sanitise search input (#2280) ## [2.9.1] - 2024-02-29 ### Fixed @@ -254,7 +256,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.5.0] - 2021-04-20 ### Added - Remove `condition` in query schema, please use `filter` instead (#260) -- `@jsonField` annotation is now supported in `graphql.schema` which allows you to store structured data JSON data in a single database field +- annotation is now supported in - We'll automatically generate coresponding JSON interfaces when querying this data (#275) - Read more about how you can use this in our [updated docs](https://doc.subquery.network/create/graphql.html#json-type) diff --git a/packages/query/package.json b/packages/query/package.json index 27c733b63c..552019462c 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -50,6 +50,7 @@ "graphql-query-complexity": "^0.11.0", "lodash": "^4.17.21", "pg": "^8.7.1", + "pg-tsquery": "^8.4.2", "postgraphile": "^4.13.0", "postgraphile-plugin-connection-filter": "^2.2.2", "reflect-metadata": "^0.1.13", diff --git a/packages/query/src/graphql/plugins/PgSearchPlugin.ts b/packages/query/src/graphql/plugins/PgSearchPlugin.ts new file mode 100644 index 0000000000..cd276dc295 --- /dev/null +++ b/packages/query/src/graphql/plugins/PgSearchPlugin.ts @@ -0,0 +1,31 @@ +// Copyright 2020-2024 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0 + +import {PgEntity, PgEntityKind, PgProc} from '@subql/x-graphile-build-pg'; +import {Plugin, Context} from 'graphile-build'; +import {Tsquery} from 'pg-tsquery'; + +const parser = new Tsquery(); + +function isProcedure(entity?: PgEntity): entity is PgProc { + return entity?.kind === PgEntityKind.PROCEDURE; +} + +export const PgSearchPlugin: Plugin = (builder) => { + // Sanitises the search argument for fulltext search using pg-tsquery + builder.hook('GraphQLObjectType:fields:field', (field, build, {scope: {pgFieldIntrospection}}: Context) => { + if (isProcedure(pgFieldIntrospection) && pgFieldIntrospection.argNames.includes('search')) { + return { + ...field, + resolve(source, args, ctx, info) { + if (args.search !== undefined) { + args.search = parser.parse(args.search).toString(); + } + return field.resolve?.(source, args, ctx, info); + }, + }; + } + + return field; + }); +}; diff --git a/packages/query/src/graphql/plugins/historical/utils.ts b/packages/query/src/graphql/plugins/historical/utils.ts index 0ce416f335..f9a8cafa01 100644 --- a/packages/query/src/graphql/plugins/historical/utils.ts +++ b/packages/query/src/graphql/plugins/historical/utils.ts @@ -8,7 +8,7 @@ export function makeRangeQuery(tableName: SQL, blockHeight: SQL, sql: any): SQL } // Used to filter out _block_range attributes -export function hasBlockRange(entity: PgEntity): boolean { +export function hasBlockRange(entity?: PgEntity): boolean { if (!entity) { return true; } diff --git a/packages/query/src/graphql/plugins/index.ts b/packages/query/src/graphql/plugins/index.ts index e34d300dff..ff4de95995 100644 --- a/packages/query/src/graphql/plugins/index.ts +++ b/packages/query/src/graphql/plugins/index.ts @@ -53,6 +53,7 @@ import {PgRowByVirtualIdPlugin} from './PgRowByVirtualIdPlugin'; import {PgDistinctPlugin} from './PgDistinctPlugin'; import PgConnectionArgOrderBy from './PgOrderByUnique'; import historicalPlugins from './historical'; +import {PgSearchPlugin} from './PgSearchPlugin'; /* eslint-enable */ @@ -111,6 +112,7 @@ const plugins = [ PgAggregationPlugin, PgRowByVirtualIdPlugin, PgDistinctPlugin, + PgSearchPlugin, makeAddInflectorsPlugin((inflectors) => { const {constantCase: oldConstantCase} = inflectors; const enumValues = new Set(); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 51dcedbb6d..c77218f3dd 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- New fullText graphql directive (#2280) ## [2.7.1] - 2024-02-29 ### Fixed diff --git a/packages/utils/src/graphql/constant.ts b/packages/utils/src/graphql/constant.ts index 88653fe3c1..c0ab27eea2 100644 --- a/packages/utils/src/graphql/constant.ts +++ b/packages/utils/src/graphql/constant.ts @@ -5,4 +5,5 @@ export enum DirectiveName { DerivedFrom = 'derivedFrom', Entity = 'entity', JsonField = 'jsonField', + FullText = 'fullText', } diff --git a/packages/utils/src/graphql/entities.ts b/packages/utils/src/graphql/entities.ts index e8843b66ae..0728f74ba2 100644 --- a/packages/utils/src/graphql/entities.ts +++ b/packages/utils/src/graphql/entities.ts @@ -29,6 +29,7 @@ import {buildSchemaFromFile} from './schema'; import { FieldScalar, GraphQLEntityField, + GraphQLFullTextType, GraphQLJsonFieldType, GraphQLJsonObjectType, GraphQLModelsRelationsEnums, @@ -196,6 +197,38 @@ export function getAllEntitiesRelations(_schema: GraphQLSchema | string | null): }); }); } + + // Fulltext Search + const fullTextDirective = schema.getDirective('fullText'); + const fullTextDirectiveVal = getDirectiveValues(fullTextDirective, entity.astNode) as GraphQLFullTextType; + + if (fullTextDirectiveVal) { + if (!fullTextDirectiveVal.fields.length) { + throw new Error(`Expected fullText directive to have at least one field on entity ${entity.name}`); + } + + // Make fields unique + fullTextDirectiveVal.fields = [...new Set(fullTextDirectiveVal.fields)]; + + fullTextDirectiveVal.fields.forEach((searchField, index) => { + const field = newModel.fields.find((f) => [searchField, `${searchField}Id`].includes(f.name)); + if (!field) { + throw new Error(`Field "${searchField}" in fullText directive doesn't exist on entity "${entity.name}"`); + } + + if (!['String', 'ID'].includes(field.type)) { + throw new Error(`fullText directive fields only supports String types`); + } + + // If the field is a realation, we rename the field to include _id + if (field.name === `${searchField}Id`) { + fullTextDirectiveVal.fields[index] = `${searchField}_id`; + } + }); + + newModel.fullText = fullTextDirectiveVal; + } + modelRelations.models.push(newModel); } validateRelations(modelRelations); diff --git a/packages/utils/src/graphql/graphql.spec.ts b/packages/utils/src/graphql/graphql.spec.ts index 387722438b..04ac3873b6 100644 --- a/packages/utils/src/graphql/graphql.spec.ts +++ b/packages/utils/src/graphql/graphql.spec.ts @@ -364,4 +364,50 @@ describe('utils that handle schema.graphql', () => { /Composite index on entity StarterEntity expected not more than 3 fields,/ ); }); + + it('can read fulltext directive', () => { + const graphqlSchema = gql` + type StarterEntity @entity @fullText(fields: ["field2", "field3"], language: "english") { + id: ID! #id is a required field + field1: Int! + field2: String #field2 is an optional field + field3: String + } + `; + + const schema = buildSchemaFromDocumentNode(graphqlSchema); + const entities = getAllEntitiesRelations(schema); + + expect(entities.models?.[0].fullText?.fields).toEqual(['field2', 'field3']); + }); + + it('can throw fulltext directive when field doesnt exist on entity', () => { + const graphqlSchema = gql` + type StarterEntity @entity @fullText(fields: ["field2", "not_exists"], language: "english") { + id: ID! #id is a required field + field1: Int! + field2: String #field2 is an optional field + field3: String + } + `; + + const schema = buildSchemaFromDocumentNode(graphqlSchema); + expect(() => getAllEntitiesRelations(schema)).toThrow( + `Field "not_exists" in fullText directive doesn't exist on entity "StarterEntity"` + ); + }); + + it('can throw fulltext directive when field isnt a string', () => { + const graphqlSchema = gql` + type StarterEntity @entity @fullText(fields: ["field1"], language: "english") { + id: ID! #id is a required field + field1: Int! + field2: String #field2 is an optional field + field3: String + } + `; + + const schema = buildSchemaFromDocumentNode(graphqlSchema); + expect(() => getAllEntitiesRelations(schema)).toThrow(`fullText directive fields only supports String types`); + }); }); diff --git a/packages/utils/src/graphql/schema/directives.ts b/packages/utils/src/graphql/schema/directives.ts index 348d8910d2..99ad696f84 100644 --- a/packages/utils/src/graphql/schema/directives.ts +++ b/packages/utils/src/graphql/schema/directives.ts @@ -9,4 +9,5 @@ export const directives = gql` directive @jsonField(indexed: Boolean) on OBJECT directive @index(unique: Boolean) on FIELD_DEFINITION directive @compositeIndexes(fields: [[String]]!) on OBJECT + directive @fullText(fields: [String!], language: String) on OBJECT `; diff --git a/packages/utils/src/graphql/types.ts b/packages/utils/src/graphql/types.ts index 2d31a92b02..4461a256f4 100644 --- a/packages/utils/src/graphql/types.ts +++ b/packages/utils/src/graphql/types.ts @@ -37,6 +37,8 @@ export interface GraphQLModelsType { indexes: GraphQLEntityIndex[]; + fullText?: GraphQLFullTextType; + description?: string; } @@ -73,6 +75,12 @@ export interface GraphQLEntityIndex { using?: IndexType; } +export interface GraphQLFullTextType { + fields: string[]; + + language?: string; +} + export interface GraphQLRelationsType { from: string; diff --git a/yarn.lock b/yarn.lock index 4bc4bf91b9..7b9a42d572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6631,6 +6631,7 @@ __metadata: lodash: ^4.17.21 nodemon: ^2.0.15 pg: ^8.7.1 + pg-tsquery: ^8.4.2 postgraphile: ^4.13.0 postgraphile-plugin-connection-filter: ^2.2.2 reflect-metadata: ^0.1.13 @@ -18728,6 +18729,13 @@ __metadata: languageName: node linkType: hard +"pg-tsquery@npm:^8.4.2": + version: 8.4.2 + resolution: "pg-tsquery@npm:8.4.2" + checksum: 1ab466096775573285f16ec6b2fa93906a6203f2013bb739c6c38a7b9e858cb8f55cb0bd2c7b97167edb38bae4fda2c288822f41c6a1e6507866cc1afd52ff36 + languageName: node + linkType: hard + "pg-types@npm:^2.1.0, pg-types@npm:^2.2.0": version: 2.2.0 resolution: "pg-types@npm:2.2.0"