├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── fulltext.test.js.snap ├── fulltext.test.js ├── helpers.js ├── printSchemaOrdered.js └── schema.sql ├── index.js ├── package.json ├── scripts └── test └── src └── PostgraphileFullTextFilterPlugin.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:8 11 | - image: circleci/postgres:9.6-alpine 12 | environment: 13 | POSTGRES_USER: circleci 14 | POSTGRES_DB: circle_test 15 | 16 | # Specify service dependencies here if necessary 17 | # CircleCI maintains a library of pre-built images 18 | # documented at https://circleci.com/docs/2.0/circleci-images/ 19 | # - image: circleci/mongo:3.4.4 20 | 21 | working_directory: ~/repo 22 | 23 | steps: 24 | - checkout 25 | 26 | # Download and cache dependencies 27 | - restore_cache: 28 | keys: 29 | - v1-dependencies-{{ checksum "package.json" }} 30 | # fallback to using the latest cache if no exact match is found 31 | - v1-dependencies- 32 | 33 | - run: 34 | name: Installing dependencies 35 | command: | 36 | yarn install 37 | sudo apt-get install postgresql-client 38 | 39 | - save_cache: 40 | paths: 41 | - node_modules 42 | key: v1-dependencies-{{ checksum "package.json" }} 43 | 44 | - run: 45 | name: Waiting for Postgres to be ready 46 | command: | 47 | for i in `seq 1 10`; 48 | do 49 | nc -z localhost 5432 && echo Success && exit 0 50 | echo -n . 51 | sleep 1 52 | done 53 | echo Failed waiting for Postgres && exit 1 54 | 55 | # run tests! 56 | - run: 57 | name: Run linter 58 | command: yarn lint --format junit -o reports/junit/js-lint-results.xml 59 | 60 | - run: 61 | name: Run unit tests 62 | environment: 63 | TEST_DATABASE_URL: postgres://circleci@localhost:5432/circle_test 64 | JEST_JUNIT_OUTPUT: 'reports/junit/js-test-results.xml' 65 | command: yarn test -- --ci --testResultsProcessor="jest-junit" 66 | 67 | - store_test_results: 68 | path: reports/junit 69 | 70 | - store_artifacts: 71 | path: reports/junit 72 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | 10 | [Makefile] 11 | indent_style = tab 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # testing 5 | coverage 6 | # production 7 | build 8 | .netlify 9 | dist 10 | 11 | # misc 12 | .DS_Store 13 | npm-debug.log 14 | yarn-error.log 15 | 16 | #IDE 17 | .idea/* 18 | *.swp 19 | .vscode 20 | 21 | .env 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mark Lipscombe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Package on npm](https://img.shields.io/npm/v/postgraphile-plugin-fulltext-filter.svg)](https://www.npmjs.com/package/postgraphile-plugin-fulltext-filter) 2 | [![CircleCI](https://circleci.com/gh/mlipscombe/postgraphile-plugin-fulltext-filter/tree/master.svg?style=svg)](https://circleci.com/gh/mlipscombe/postgraphile-plugin-fulltext-filter/tree/master) 3 | 4 | # postgraphile-plugin-fulltext-filter 5 | This plugin implements a full text search operator for `tsvector` columns in PostGraphile v4 via @mattbretl's excellent `postgraphile-plugin-connection-filter` plugin. 6 | 7 | ## Getting Started 8 | 9 | ### CLI 10 | 11 | ``` bash 12 | postgraphile --append-plugins postgraphile-plugin-connection-filter,postgraphile-plugin-fulltext-filter 13 | ``` 14 | 15 | See [here](https://www.graphile.org/postgraphile/extending/#loading-additional-plugins) for 16 | more information about loading plugins with PostGraphile. 17 | 18 | ### Library 19 | 20 | ``` js 21 | const express = require('express'); 22 | const { postgraphile } = require('postgraphile'); 23 | const PostGraphileConnectionFilterPlugin = require('postgraphile-plugin-connection-filter'); 24 | const PostGraphileFulltextFilterPlugin = require('postgraphile-plugin-fulltext-filter'); 25 | 26 | const app = express(); 27 | 28 | app.use( 29 | postgraphile(pgConfig, schema, { 30 | appendPlugins: [ 31 | PostGraphileConnectionFilterPlugin, 32 | PostGraphileFulltextFilterPlugin, 33 | ], 34 | }) 35 | ); 36 | 37 | app.listen(5000); 38 | ``` 39 | 40 | ## Performance 41 | 42 | All `tsvector` columns that aren't @omit'd should have indexes on them: 43 | 44 | ``` sql 45 | ALTER TABLE posts ADD COLUMN full_text tsvector; 46 | CREATE INDEX full_text_idx ON posts USING gin(full_text); 47 | ``` 48 | 49 | ## Operators 50 | 51 | This plugin adds the `matches` filter operator to the filter plugin, accepting 52 | a GraphQL String input and using the `@@` operator to perform full-text searches 53 | on `tsvector` columns. 54 | 55 | This plugin uses [pg-tsquery](https://github.com/caub/pg-tsquery) to parse the 56 | user input to prevent Postgres throwing on bad user input unnecessarily. 57 | 58 | ## Fields 59 | 60 | For each `tsvector` column, a rank column will be automatically added to the 61 | GraphQL type for the table by appending `Rank` to the end of the column's name. 62 | For example, a column `full_text` will appear as `fullText` in the GraphQL type, 63 | and a second column, `fullTextRank` will be added to the type as a `Float`. 64 | 65 | This rank field can be used for ordering and is automatically added to the orderBy 66 | enum for the table. 67 | 68 | ## Examples 69 | 70 | ``` graphql 71 | query { 72 | allPosts( 73 | filter: { 74 | fullText: { matches: 'foo -bar' } 75 | } 76 | orderBy: FULL_TEXT_RANK_DESC 77 | }) { 78 | ... 79 | fullTextRank 80 | } 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/fulltext.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`fulltext search field is created 1`] = ` 4 | GraphQLSchema { 5 | "__allowedLegacyNames": Array [], 6 | "__validationErrors": undefined, 7 | "_directives": Array [ 8 | "@include", 9 | "@skip", 10 | "@deprecated", 11 | ], 12 | "_implementations": Object { 13 | "Node": Array [ 14 | "Query", 15 | "Job", 16 | ], 17 | }, 18 | "_mutationType": "Mutation", 19 | "_possibleTypeMap": Object {}, 20 | "_queryType": "Query", 21 | "_subscriptionType": undefined, 22 | "_typeMap": Object { 23 | "Boolean": "Boolean", 24 | "CreateJobInput": "CreateJobInput", 25 | "CreateJobPayload": "CreateJobPayload", 26 | "Cursor": "Cursor", 27 | "DeleteJobByIdInput": "DeleteJobByIdInput", 28 | "DeleteJobInput": "DeleteJobInput", 29 | "DeleteJobPayload": "DeleteJobPayload", 30 | "Float": "Float", 31 | "FullText": "FullText", 32 | "FullTextFilter": "FullTextFilter", 33 | "ID": "ID", 34 | "Int": "Int", 35 | "IntFilter": "IntFilter", 36 | "Job": "Job", 37 | "JobCondition": "JobCondition", 38 | "JobFilter": "JobFilter", 39 | "JobInput": "JobInput", 40 | "JobPatch": "JobPatch", 41 | "JobsConnection": "JobsConnection", 42 | "JobsEdge": "JobsEdge", 43 | "JobsOrderBy": "JobsOrderBy", 44 | "Mutation": "Mutation", 45 | "Node": "Node", 46 | "PageInfo": "PageInfo", 47 | "Query": "Query", 48 | "String": "String", 49 | "StringFilter": "StringFilter", 50 | "UpdateJobByIdInput": "UpdateJobByIdInput", 51 | "UpdateJobInput": "UpdateJobInput", 52 | "UpdateJobPayload": "UpdateJobPayload", 53 | "__Directive": "__Directive", 54 | "__DirectiveLocation": "__DirectiveLocation", 55 | "__EnumValue": "__EnumValue", 56 | "__Field": "__Field", 57 | "__InputValue": "__InputValue", 58 | "__Schema": "__Schema", 59 | "__Type": "__Type", 60 | "__TypeKind": "__TypeKind", 61 | }, 62 | "astNode": undefined, 63 | "extensionASTNodes": undefined, 64 | } 65 | `; 66 | 67 | exports[`fulltext search field is created 2`] = ` 68 | GraphQLSchema { 69 | "__allowedLegacyNames": Array [], 70 | "__validationErrors": undefined, 71 | "_directives": Array [ 72 | "@include", 73 | "@skip", 74 | "@deprecated", 75 | ], 76 | "_implementations": Object { 77 | "Node": Array [ 78 | "Query", 79 | "Job", 80 | ], 81 | }, 82 | "_mutationType": "Mutation", 83 | "_possibleTypeMap": Object {}, 84 | "_queryType": "Query", 85 | "_subscriptionType": undefined, 86 | "_typeMap": Object { 87 | "Boolean": "Boolean", 88 | "CreateJobInput": "CreateJobInput", 89 | "CreateJobPayload": "CreateJobPayload", 90 | "Cursor": "Cursor", 91 | "DeleteJobByIdInput": "DeleteJobByIdInput", 92 | "DeleteJobInput": "DeleteJobInput", 93 | "DeleteJobPayload": "DeleteJobPayload", 94 | "Float": "Float", 95 | "FullText": "FullText", 96 | "FullTextFilter": "FullTextFilter", 97 | "ID": "ID", 98 | "Int": "Int", 99 | "IntFilter": "IntFilter", 100 | "Job": "Job", 101 | "JobCondition": "JobCondition", 102 | "JobFilter": "JobFilter", 103 | "JobInput": "JobInput", 104 | "JobPatch": "JobPatch", 105 | "JobsConnection": "JobsConnection", 106 | "JobsEdge": "JobsEdge", 107 | "JobsOrderBy": "JobsOrderBy", 108 | "Mutation": "Mutation", 109 | "Node": "Node", 110 | "PageInfo": "PageInfo", 111 | "Query": "Query", 112 | "String": "String", 113 | "StringFilter": "StringFilter", 114 | "UpdateJobByIdInput": "UpdateJobByIdInput", 115 | "UpdateJobInput": "UpdateJobInput", 116 | "UpdateJobPayload": "UpdateJobPayload", 117 | "__Directive": "__Directive", 118 | "__DirectiveLocation": "__DirectiveLocation", 119 | "__EnumValue": "__EnumValue", 120 | "__Field": "__Field", 121 | "__InputValue": "__InputValue", 122 | "__Schema": "__Schema", 123 | "__Type": "__Type", 124 | "__TypeKind": "__TypeKind", 125 | }, 126 | "astNode": undefined, 127 | "extensionASTNodes": undefined, 128 | } 129 | `; 130 | 131 | exports[`querying rank without filter works 1`] = ` 132 | GraphQLSchema { 133 | "__allowedLegacyNames": Array [], 134 | "__validationErrors": undefined, 135 | "_directives": Array [ 136 | "@include", 137 | "@skip", 138 | "@deprecated", 139 | ], 140 | "_implementations": Object { 141 | "Node": Array [ 142 | "Query", 143 | "Job", 144 | ], 145 | }, 146 | "_mutationType": "Mutation", 147 | "_possibleTypeMap": Object {}, 148 | "_queryType": "Query", 149 | "_subscriptionType": undefined, 150 | "_typeMap": Object { 151 | "Boolean": "Boolean", 152 | "CreateJobInput": "CreateJobInput", 153 | "CreateJobPayload": "CreateJobPayload", 154 | "Cursor": "Cursor", 155 | "DeleteJobByIdInput": "DeleteJobByIdInput", 156 | "DeleteJobInput": "DeleteJobInput", 157 | "DeleteJobPayload": "DeleteJobPayload", 158 | "Float": "Float", 159 | "FullText": "FullText", 160 | "FullTextFilter": "FullTextFilter", 161 | "ID": "ID", 162 | "Int": "Int", 163 | "IntFilter": "IntFilter", 164 | "Job": "Job", 165 | "JobCondition": "JobCondition", 166 | "JobFilter": "JobFilter", 167 | "JobInput": "JobInput", 168 | "JobPatch": "JobPatch", 169 | "JobsConnection": "JobsConnection", 170 | "JobsEdge": "JobsEdge", 171 | "JobsOrderBy": "JobsOrderBy", 172 | "Mutation": "Mutation", 173 | "Node": "Node", 174 | "PageInfo": "PageInfo", 175 | "Query": "Query", 176 | "String": "String", 177 | "StringFilter": "StringFilter", 178 | "UpdateJobByIdInput": "UpdateJobByIdInput", 179 | "UpdateJobInput": "UpdateJobInput", 180 | "UpdateJobPayload": "UpdateJobPayload", 181 | "__Directive": "__Directive", 182 | "__DirectiveLocation": "__DirectiveLocation", 183 | "__EnumValue": "__EnumValue", 184 | "__Field": "__Field", 185 | "__InputValue": "__InputValue", 186 | "__Schema": "__Schema", 187 | "__Type": "__Type", 188 | "__TypeKind": "__TypeKind", 189 | }, 190 | "astNode": undefined, 191 | "extensionASTNodes": undefined, 192 | } 193 | `; 194 | 195 | exports[`sort by full text rank field works 1`] = ` 196 | GraphQLSchema { 197 | "__allowedLegacyNames": Array [], 198 | "__validationErrors": undefined, 199 | "_directives": Array [ 200 | "@include", 201 | "@skip", 202 | "@deprecated", 203 | ], 204 | "_implementations": Object { 205 | "Node": Array [ 206 | "Query", 207 | "Job", 208 | ], 209 | }, 210 | "_mutationType": "Mutation", 211 | "_possibleTypeMap": Object {}, 212 | "_queryType": "Query", 213 | "_subscriptionType": undefined, 214 | "_typeMap": Object { 215 | "Boolean": "Boolean", 216 | "CreateJobInput": "CreateJobInput", 217 | "CreateJobPayload": "CreateJobPayload", 218 | "Cursor": "Cursor", 219 | "DeleteJobByIdInput": "DeleteJobByIdInput", 220 | "DeleteJobInput": "DeleteJobInput", 221 | "DeleteJobPayload": "DeleteJobPayload", 222 | "Float": "Float", 223 | "FullText": "FullText", 224 | "FullTextFilter": "FullTextFilter", 225 | "ID": "ID", 226 | "Int": "Int", 227 | "IntFilter": "IntFilter", 228 | "Job": "Job", 229 | "JobCondition": "JobCondition", 230 | "JobFilter": "JobFilter", 231 | "JobInput": "JobInput", 232 | "JobPatch": "JobPatch", 233 | "JobsConnection": "JobsConnection", 234 | "JobsEdge": "JobsEdge", 235 | "JobsOrderBy": "JobsOrderBy", 236 | "Mutation": "Mutation", 237 | "Node": "Node", 238 | "PageInfo": "PageInfo", 239 | "Query": "Query", 240 | "String": "String", 241 | "StringFilter": "StringFilter", 242 | "UpdateJobByIdInput": "UpdateJobByIdInput", 243 | "UpdateJobInput": "UpdateJobInput", 244 | "UpdateJobPayload": "UpdateJobPayload", 245 | "__Directive": "__Directive", 246 | "__DirectiveLocation": "__DirectiveLocation", 247 | "__EnumValue": "__EnumValue", 248 | "__Field": "__Field", 249 | "__InputValue": "__InputValue", 250 | "__Schema": "__Schema", 251 | "__Type": "__Type", 252 | "__TypeKind": "__TypeKind", 253 | }, 254 | "astNode": undefined, 255 | "extensionASTNodes": undefined, 256 | } 257 | `; 258 | 259 | exports[`table with unfiltered full-text field works 1`] = ` 260 | GraphQLSchema { 261 | "__allowedLegacyNames": Array [], 262 | "__validationErrors": undefined, 263 | "_directives": Array [ 264 | "@include", 265 | "@skip", 266 | "@deprecated", 267 | ], 268 | "_implementations": Object { 269 | "Node": Array [ 270 | "Query", 271 | "Job", 272 | ], 273 | }, 274 | "_mutationType": "Mutation", 275 | "_possibleTypeMap": Object {}, 276 | "_queryType": "Query", 277 | "_subscriptionType": undefined, 278 | "_typeMap": Object { 279 | "Boolean": "Boolean", 280 | "CreateJobInput": "CreateJobInput", 281 | "CreateJobPayload": "CreateJobPayload", 282 | "Cursor": "Cursor", 283 | "DeleteJobByIdInput": "DeleteJobByIdInput", 284 | "DeleteJobInput": "DeleteJobInput", 285 | "DeleteJobPayload": "DeleteJobPayload", 286 | "Float": "Float", 287 | "FullText": "FullText", 288 | "FullTextFilter": "FullTextFilter", 289 | "ID": "ID", 290 | "Int": "Int", 291 | "IntFilter": "IntFilter", 292 | "Job": "Job", 293 | "JobCondition": "JobCondition", 294 | "JobFilter": "JobFilter", 295 | "JobInput": "JobInput", 296 | "JobPatch": "JobPatch", 297 | "JobsConnection": "JobsConnection", 298 | "JobsEdge": "JobsEdge", 299 | "JobsOrderBy": "JobsOrderBy", 300 | "Mutation": "Mutation", 301 | "Node": "Node", 302 | "PageInfo": "PageInfo", 303 | "Query": "Query", 304 | "String": "String", 305 | "StringFilter": "StringFilter", 306 | "UpdateJobByIdInput": "UpdateJobByIdInput", 307 | "UpdateJobInput": "UpdateJobInput", 308 | "UpdateJobPayload": "UpdateJobPayload", 309 | "__Directive": "__Directive", 310 | "__DirectiveLocation": "__DirectiveLocation", 311 | "__EnumValue": "__EnumValue", 312 | "__Field": "__Field", 313 | "__InputValue": "__InputValue", 314 | "__Schema": "__Schema", 315 | "__Type": "__Type", 316 | "__TypeKind": "__TypeKind", 317 | }, 318 | "astNode": undefined, 319 | "extensionASTNodes": undefined, 320 | } 321 | `; 322 | 323 | exports[`works with connectionFilterRelations 1`] = ` 324 | GraphQLSchema { 325 | "__allowedLegacyNames": Array [], 326 | "__validationErrors": undefined, 327 | "_directives": Array [ 328 | "@include", 329 | "@skip", 330 | "@deprecated", 331 | ], 332 | "_implementations": Object { 333 | "Node": Array [ 334 | "Query", 335 | "Client", 336 | "Order", 337 | ], 338 | }, 339 | "_mutationType": "Mutation", 340 | "_possibleTypeMap": Object {}, 341 | "_queryType": "Query", 342 | "_subscriptionType": undefined, 343 | "_typeMap": Object { 344 | "Boolean": "Boolean", 345 | "Client": "Client", 346 | "ClientCondition": "ClientCondition", 347 | "ClientFilter": "ClientFilter", 348 | "ClientInput": "ClientInput", 349 | "ClientPatch": "ClientPatch", 350 | "ClientToManyOrderFilter": "ClientToManyOrderFilter", 351 | "ClientsConnection": "ClientsConnection", 352 | "ClientsEdge": "ClientsEdge", 353 | "ClientsOrderBy": "ClientsOrderBy", 354 | "CreateClientInput": "CreateClientInput", 355 | "CreateClientPayload": "CreateClientPayload", 356 | "CreateOrderInput": "CreateOrderInput", 357 | "CreateOrderPayload": "CreateOrderPayload", 358 | "Cursor": "Cursor", 359 | "DeleteClientByIdInput": "DeleteClientByIdInput", 360 | "DeleteClientInput": "DeleteClientInput", 361 | "DeleteClientPayload": "DeleteClientPayload", 362 | "DeleteOrderByIdInput": "DeleteOrderByIdInput", 363 | "DeleteOrderInput": "DeleteOrderInput", 364 | "DeleteOrderPayload": "DeleteOrderPayload", 365 | "Float": "Float", 366 | "FullText": "FullText", 367 | "FullTextFilter": "FullTextFilter", 368 | "ID": "ID", 369 | "Int": "Int", 370 | "IntFilter": "IntFilter", 371 | "Mutation": "Mutation", 372 | "Node": "Node", 373 | "Order": "Order", 374 | "OrderCondition": "OrderCondition", 375 | "OrderFilter": "OrderFilter", 376 | "OrderInput": "OrderInput", 377 | "OrderPatch": "OrderPatch", 378 | "OrdersConnection": "OrdersConnection", 379 | "OrdersEdge": "OrdersEdge", 380 | "OrdersOrderBy": "OrdersOrderBy", 381 | "PageInfo": "PageInfo", 382 | "Query": "Query", 383 | "String": "String", 384 | "StringFilter": "StringFilter", 385 | "UpdateClientByIdInput": "UpdateClientByIdInput", 386 | "UpdateClientInput": "UpdateClientInput", 387 | "UpdateClientPayload": "UpdateClientPayload", 388 | "UpdateOrderByIdInput": "UpdateOrderByIdInput", 389 | "UpdateOrderInput": "UpdateOrderInput", 390 | "UpdateOrderPayload": "UpdateOrderPayload", 391 | "__Directive": "__Directive", 392 | "__DirectiveLocation": "__DirectiveLocation", 393 | "__EnumValue": "__EnumValue", 394 | "__Field": "__Field", 395 | "__InputValue": "__InputValue", 396 | "__Schema": "__Schema", 397 | "__Type": "__Type", 398 | "__TypeKind": "__TypeKind", 399 | }, 400 | "astNode": undefined, 401 | "extensionASTNodes": undefined, 402 | } 403 | `; 404 | 405 | exports[`works with connectionFilterRelations with no local filter 1`] = ` 406 | GraphQLSchema { 407 | "__allowedLegacyNames": Array [], 408 | "__validationErrors": undefined, 409 | "_directives": Array [ 410 | "@include", 411 | "@skip", 412 | "@deprecated", 413 | ], 414 | "_implementations": Object { 415 | "Node": Array [ 416 | "Query", 417 | "Client", 418 | "Order", 419 | ], 420 | }, 421 | "_mutationType": "Mutation", 422 | "_possibleTypeMap": Object {}, 423 | "_queryType": "Query", 424 | "_subscriptionType": undefined, 425 | "_typeMap": Object { 426 | "Boolean": "Boolean", 427 | "Client": "Client", 428 | "ClientCondition": "ClientCondition", 429 | "ClientFilter": "ClientFilter", 430 | "ClientInput": "ClientInput", 431 | "ClientPatch": "ClientPatch", 432 | "ClientToManyOrderFilter": "ClientToManyOrderFilter", 433 | "ClientsConnection": "ClientsConnection", 434 | "ClientsEdge": "ClientsEdge", 435 | "ClientsOrderBy": "ClientsOrderBy", 436 | "CreateClientInput": "CreateClientInput", 437 | "CreateClientPayload": "CreateClientPayload", 438 | "CreateOrderInput": "CreateOrderInput", 439 | "CreateOrderPayload": "CreateOrderPayload", 440 | "Cursor": "Cursor", 441 | "DeleteClientByIdInput": "DeleteClientByIdInput", 442 | "DeleteClientInput": "DeleteClientInput", 443 | "DeleteClientPayload": "DeleteClientPayload", 444 | "DeleteOrderByIdInput": "DeleteOrderByIdInput", 445 | "DeleteOrderInput": "DeleteOrderInput", 446 | "DeleteOrderPayload": "DeleteOrderPayload", 447 | "Float": "Float", 448 | "FullText": "FullText", 449 | "FullTextFilter": "FullTextFilter", 450 | "ID": "ID", 451 | "Int": "Int", 452 | "IntFilter": "IntFilter", 453 | "Mutation": "Mutation", 454 | "Node": "Node", 455 | "Order": "Order", 456 | "OrderCondition": "OrderCondition", 457 | "OrderFilter": "OrderFilter", 458 | "OrderInput": "OrderInput", 459 | "OrderPatch": "OrderPatch", 460 | "OrdersConnection": "OrdersConnection", 461 | "OrdersEdge": "OrdersEdge", 462 | "OrdersOrderBy": "OrdersOrderBy", 463 | "PageInfo": "PageInfo", 464 | "Query": "Query", 465 | "String": "String", 466 | "StringFilter": "StringFilter", 467 | "UpdateClientByIdInput": "UpdateClientByIdInput", 468 | "UpdateClientInput": "UpdateClientInput", 469 | "UpdateClientPayload": "UpdateClientPayload", 470 | "UpdateOrderByIdInput": "UpdateOrderByIdInput", 471 | "UpdateOrderInput": "UpdateOrderInput", 472 | "UpdateOrderPayload": "UpdateOrderPayload", 473 | "__Directive": "__Directive", 474 | "__DirectiveLocation": "__DirectiveLocation", 475 | "__EnumValue": "__EnumValue", 476 | "__Field": "__Field", 477 | "__InputValue": "__InputValue", 478 | "__Schema": "__Schema", 479 | "__Type": "__Type", 480 | "__TypeKind": "__TypeKind", 481 | }, 482 | "astNode": undefined, 483 | "extensionASTNodes": undefined, 484 | } 485 | `; 486 | -------------------------------------------------------------------------------- /__tests__/fulltext.test.js: -------------------------------------------------------------------------------- 1 | const { graphql } = require('graphql'); 2 | const { withSchema } = require('./helpers'); 3 | 4 | test( 5 | 'table with unfiltered full-text field works', 6 | withSchema({ 7 | setup: ` 8 | create table fulltext_test.job ( 9 | id serial primary key, 10 | name text not null, 11 | full_text tsvector 12 | ); 13 | insert into fulltext_test.job (name, full_text) values 14 | ('test', to_tsvector('apple fruit')), 15 | ('test 2', to_tsvector('banana fruit')); 16 | `, 17 | test: async ({ schema, pgClient }) => { 18 | const query = ` 19 | query { 20 | allJobs { 21 | nodes { 22 | id 23 | name 24 | } 25 | } 26 | } 27 | `; 28 | expect(schema).toMatchSnapshot(); 29 | 30 | const result = await graphql(schema, query, null, { pgClient }); 31 | expect(result).not.toHaveProperty('errors'); 32 | }, 33 | }), 34 | ); 35 | 36 | test( 37 | 'fulltext search field is created', 38 | withSchema({ 39 | setup: ` 40 | create table fulltext_test.job ( 41 | id serial primary key, 42 | name text not null, 43 | full_text tsvector 44 | ); 45 | insert into fulltext_test.job (name, full_text) values 46 | ('test', to_tsvector('apple fruit')), 47 | ('test 2', to_tsvector('banana fruit')); 48 | `, 49 | test: async ({ schema, pgClient }) => { 50 | const query = ` 51 | query { 52 | allJobs( 53 | filter: { 54 | fullText: { 55 | matches: "fruit" 56 | } 57 | } 58 | orderBy: [ 59 | FULL_TEXT_RANK_ASC 60 | ] 61 | ) { 62 | nodes { 63 | id 64 | name 65 | fullTextRank 66 | } 67 | } 68 | } 69 | `; 70 | expect(schema).toMatchSnapshot(); 71 | 72 | const result = await graphql(schema, query, null, { pgClient }); 73 | expect(result).not.toHaveProperty('errors'); 74 | 75 | const data = result.data.allJobs.nodes; 76 | expect(data).toHaveLength(2); 77 | data.map(n => expect(n.fullTextRank).not.toBeNull()); 78 | 79 | const bananaQuery = ` 80 | query { 81 | allJobs( 82 | filter: { 83 | fullText: { 84 | matches: "banana" 85 | } 86 | } 87 | ) { 88 | nodes { 89 | id 90 | name 91 | fullTextRank 92 | } 93 | } 94 | } 95 | `; 96 | const bananaResult = await graphql(schema, bananaQuery, null, { pgClient }); 97 | expect(bananaResult).not.toHaveProperty('errors'); 98 | 99 | const bananaData = bananaResult.data.allJobs.nodes; 100 | expect(bananaData).toHaveLength(1); 101 | bananaData.map(n => expect(n.fullTextRank).not.toBeNull()); 102 | }, 103 | }), 104 | ); 105 | 106 | test( 107 | 'querying rank without filter works', 108 | withSchema({ 109 | setup: ` 110 | create table fulltext_test.job ( 111 | id serial primary key, 112 | name text not null, 113 | full_text tsvector 114 | ); 115 | insert into fulltext_test.job (name, full_text) values 116 | ('test', to_tsvector('apple fruit')), 117 | ('test 2', to_tsvector('banana fruit')); 118 | `, 119 | test: async ({ schema, pgClient }) => { 120 | const query = ` 121 | query { 122 | allJobs { 123 | nodes { 124 | id 125 | name 126 | fullTextRank 127 | } 128 | } 129 | } 130 | `; 131 | expect(schema).toMatchSnapshot(); 132 | 133 | const result = await graphql(schema, query, null, { pgClient }); 134 | expect(result).not.toHaveProperty('errors'); 135 | 136 | const data = result.data.allJobs.nodes; 137 | expect(data).toHaveLength(2); 138 | data.map(n => expect(n.fullTextRank).toBeNull()); 139 | }, 140 | }), 141 | ); 142 | 143 | test( 144 | 'fulltext search field is created', 145 | withSchema({ 146 | setup: ` 147 | create table fulltext_test.job ( 148 | id serial primary key, 149 | name text not null, 150 | full_text tsvector, 151 | other_full_text tsvector 152 | ); 153 | insert into fulltext_test.job (name, full_text, other_full_text) values 154 | ('test', to_tsvector('apple fruit'), to_tsvector('vegetable potato')), 155 | ('test 2', to_tsvector('banana fruit'), to_tsvector('vegetable pumpkin')); 156 | `, 157 | test: async ({ schema, pgClient }) => { 158 | const query = ` 159 | query { 160 | allJobs( 161 | filter: { 162 | fullText: { 163 | matches: "fruit" 164 | } 165 | otherFullText: { 166 | matches: "vegetable" 167 | } 168 | } 169 | orderBy: [ 170 | FULL_TEXT_RANK_ASC 171 | OTHER_FULL_TEXT_DESC 172 | ] 173 | ) { 174 | nodes { 175 | id 176 | name 177 | fullTextRank 178 | otherFullTextRank 179 | } 180 | } 181 | } 182 | `; 183 | expect(schema).toMatchSnapshot(); 184 | 185 | const result = await graphql(schema, query, null, { pgClient }); 186 | expect(result).not.toHaveProperty('errors'); 187 | 188 | const data = result.data.allJobs.nodes; 189 | expect(data).toHaveLength(2); 190 | data.map(n => expect(n.fullTextRank).not.toBeNull()); 191 | data.map(n => expect(n.otherFullTextRank).not.toBeNull()); 192 | 193 | const potatoQuery = ` 194 | query { 195 | allJobs( 196 | filter: { 197 | otherFullText: { 198 | matches: "potato" 199 | } 200 | } 201 | ) { 202 | nodes { 203 | id 204 | name 205 | fullTextRank 206 | otherFullTextRank 207 | } 208 | } 209 | } 210 | `; 211 | const potatoResult = await graphql(schema, potatoQuery, null, { pgClient }); 212 | expect(potatoResult).not.toHaveProperty('errors'); 213 | 214 | const potatoData = potatoResult.data.allJobs.nodes; 215 | expect(potatoData).toHaveLength(1); 216 | potatoData.map(n => expect(n.fullTextRank).toBeNull()); 217 | potatoData.map(n => expect(n.otherFullTextRank).not.toBeNull()); 218 | }, 219 | }), 220 | ); 221 | 222 | test( 223 | 'sort by full text rank field works', 224 | withSchema({ 225 | setup: ` 226 | create table fulltext_test.job ( 227 | id serial primary key, 228 | name text not null, 229 | full_text tsvector 230 | ); 231 | insert into fulltext_test.job (name, full_text) values 232 | ('test', to_tsvector('apple fruit')), 233 | ('test 2', to_tsvector('banana fruit')); 234 | `, 235 | test: async ({ schema, pgClient }) => { 236 | const query = ` 237 | query orderByQuery($orderBy: [JobsOrderBy!]!) { 238 | allJobs( 239 | filter: { 240 | fullText: { 241 | matches: "fruit | banana" 242 | } 243 | } 244 | orderBy: $orderBy 245 | ) { 246 | nodes { 247 | id 248 | name 249 | fullTextRank 250 | } 251 | } 252 | } 253 | `; 254 | expect(schema).toMatchSnapshot(); 255 | 256 | const ascResult = await graphql(schema, query, null, { pgClient }, { orderBy: ['FULL_TEXT_ASC'] }); 257 | expect(ascResult).not.toHaveProperty('errors'); 258 | 259 | const descResult = await graphql(schema, query, null, { pgClient }, { orderBy: ['FULL_TEXT_DESC'] }); 260 | expect(descResult).not.toHaveProperty('errors'); 261 | 262 | expect(ascResult).not.toEqual(descResult); 263 | }, 264 | }), 265 | ); 266 | 267 | test( 268 | 'works with connectionFilterRelations', 269 | withSchema({ 270 | options: { 271 | graphileBuildOptions: { 272 | connectionFilterRelations: true, 273 | }, 274 | }, 275 | setup: ` 276 | create table fulltext_test.clients ( 277 | id serial primary key, 278 | comment text, 279 | tsv tsvector 280 | ); 281 | 282 | create table fulltext_test.orders ( 283 | id serial primary key, 284 | client_id integer references fulltext_test.clients (id), 285 | comment text, 286 | tsv tsvector 287 | ); 288 | 289 | insert into fulltext_test.clients (id, comment, tsv) values 290 | (1, 'Client A', tsvector('fruit apple')), 291 | (2, 'Client Z', tsvector('fruit avocado')); 292 | 293 | insert into fulltext_test.orders (id, client_id, comment, tsv) values 294 | (1, 1, 'X', tsvector('fruit apple')), 295 | (2, 1, 'Y', tsvector('fruit pear apple')), 296 | (3, 1, 'Z', tsvector('vegetable potato')), 297 | (4, 2, 'X', tsvector('fruit apple')), 298 | (5, 2, 'Y', tsvector('fruit tomato')), 299 | (6, 2, 'Z', tsvector('vegetable')); 300 | `, 301 | test: async ({ schema, pgClient }) => { 302 | const query = ` 303 | query { 304 | allOrders(filter: { 305 | or: [ 306 | { comment: { includes: "Z"} }, 307 | { clientByClientId: { tsv: { matches: "apple" } } } 308 | ] 309 | }) { 310 | nodes { 311 | id 312 | comment 313 | clientByClientId { 314 | id 315 | comment 316 | } 317 | } 318 | } 319 | } 320 | `; 321 | expect(schema).toMatchSnapshot(); 322 | 323 | const result = await graphql(schema, query, null, { pgClient }); 324 | expect(result).not.toHaveProperty('errors'); 325 | expect(result.data.allOrders.nodes).toHaveLength(2); 326 | }, 327 | }), 328 | ); 329 | 330 | test( 331 | 'works with connectionFilterRelations with no local filter', 332 | withSchema({ 333 | options: { 334 | graphileBuildOptions: { 335 | connectionFilterRelations: true, 336 | }, 337 | }, 338 | setup: ` 339 | create table fulltext_test.clients ( 340 | id serial primary key, 341 | comment text, 342 | tsv tsvector 343 | ); 344 | 345 | create table fulltext_test.orders ( 346 | id serial primary key, 347 | client_id integer references fulltext_test.clients (id), 348 | comment text, 349 | tsv tsvector 350 | ); 351 | 352 | insert into fulltext_test.clients (id, comment, tsv) values 353 | (1, 'Client A', tsvector('fruit apple')), 354 | (2, 'Client Z', tsvector('fruit avocado')); 355 | 356 | insert into fulltext_test.orders (id, client_id, comment, tsv) values 357 | (1, 1, 'X', tsvector('fruit apple')), 358 | (2, 1, 'Y', tsvector('fruit pear apple')), 359 | (3, 1, 'Z', tsvector('vegetable potato')), 360 | (4, 2, 'X', tsvector('fruit apple')), 361 | (5, 2, 'Y', tsvector('fruit tomato')), 362 | (6, 2, 'Z', tsvector('vegetable')); 363 | `, 364 | test: async ({ schema, pgClient }) => { 365 | const query = ` 366 | query { 367 | allOrders(filter: { 368 | clientByClientId: { tsv: { matches: "avocado" } } 369 | }) { 370 | nodes { 371 | id 372 | comment 373 | tsv 374 | clientByClientId { 375 | id 376 | comment 377 | tsv 378 | } 379 | } 380 | } 381 | } 382 | `; 383 | expect(schema).toMatchSnapshot(); 384 | 385 | const result = await graphql(schema, query, null, { pgClient }); 386 | expect(result).not.toHaveProperty('errors'); 387 | expect(result.data.allOrders.nodes).toHaveLength(3); 388 | }, 389 | }), 390 | ); 391 | -------------------------------------------------------------------------------- /__tests__/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const pg = require('pg'); 3 | const { readFile } = require('fs'); 4 | const pgConnectionString = require('pg-connection-string'); 5 | const { createPostGraphileSchema } = require('postgraphile-core'); 6 | 7 | // This test suite can be flaky. Increase it’s timeout. 8 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 20; 9 | 10 | function readFilePromise(filename, encoding) { 11 | return new Promise((resolve, reject) => { 12 | readFile(filename, encoding, (err, res) => { 13 | if (err) reject(err); 14 | else resolve(res); 15 | }); 16 | }); 17 | } 18 | 19 | const kitchenSinkData = () => readFilePromise(`${__dirname}/data.sql`, 'utf8'); 20 | 21 | const withPgClient = async (url, fn) => { 22 | if (!fn) { 23 | fn = url; 24 | url = process.env.TEST_DATABASE_URL; 25 | } 26 | const pgPool = new pg.Pool(pgConnectionString.parse(url)); 27 | let client; 28 | try { 29 | client = await pgPool.connect(); 30 | await client.query('begin'); 31 | await client.query('set local timezone to \'+04:00\''); 32 | const result = await fn(client); 33 | await client.query('rollback'); 34 | return result; 35 | } finally { 36 | try { 37 | await client.release(); 38 | } catch (e) { 39 | console.error('Error releasing pgClient', e); 40 | } 41 | await pgPool.end(); 42 | } 43 | }; 44 | 45 | const withDbFromUrl = async (url, fn) => withPgClient(url, async (client) => { 46 | try { 47 | await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE;'); 48 | return fn(client); 49 | } finally { 50 | await client.query('COMMIT;'); 51 | } 52 | }); 53 | 54 | 55 | const withRootDb = fn => withDbFromUrl(process.env.TEST_DATABASE_URL, fn); 56 | 57 | let prepopulatedDBKeepalive; 58 | 59 | const populateDatabase = async (client) => { 60 | await client.query(await readFilePromise(`${__dirname}/data.sql`, 'utf8')); 61 | return {}; 62 | }; 63 | 64 | const withPrepopulatedDb = async (fn) => { 65 | if (!prepopulatedDBKeepalive) { 66 | throw new Error('You must call setup and teardown to use this'); 67 | } 68 | const { client, vars } = prepopulatedDBKeepalive; 69 | if (!vars) { 70 | throw new Error('No prepopulated vars'); 71 | } 72 | let err; 73 | try { 74 | await fn(client, vars); 75 | } catch (e) { 76 | err = e; 77 | } 78 | try { 79 | await client.query('ROLLBACK TO SAVEPOINT pristine;'); 80 | } catch (e) { 81 | err = err || e; 82 | console.error('ERROR ROLLING BACK', e.message); // eslint-disable-line no-console 83 | } 84 | if (err) { 85 | throw err; 86 | } 87 | }; 88 | 89 | withPrepopulatedDb.setup = (done) => { 90 | if (prepopulatedDBKeepalive) { 91 | throw new Error("There's already a prepopulated DB running"); 92 | } 93 | let res; 94 | let rej; 95 | prepopulatedDBKeepalive = new Promise((resolve, reject) => { 96 | res = resolve; 97 | rej = reject; 98 | }); 99 | prepopulatedDBKeepalive.resolve = res; 100 | prepopulatedDBKeepalive.reject = rej; 101 | withRootDb(async (client) => { 102 | prepopulatedDBKeepalive.client = client; 103 | try { 104 | prepopulatedDBKeepalive.vars = await populateDatabase(client); 105 | } catch (e) { 106 | console.error('FAILED TO PREPOPULATE DB!', e.message); // eslint-disable-line no-console 107 | return done(e); 108 | } 109 | await client.query('SAVEPOINT pristine;'); 110 | done(); 111 | return prepopulatedDBKeepalive; 112 | }); 113 | }; 114 | 115 | withPrepopulatedDb.teardown = () => { 116 | if (!prepopulatedDBKeepalive) { 117 | throw new Error('Cannot tear down null!'); 118 | } 119 | prepopulatedDBKeepalive.resolve(); // Release DB transaction 120 | prepopulatedDBKeepalive = null; 121 | }; 122 | 123 | const withSchema = ({ 124 | setup, 125 | test, 126 | options = {}, 127 | }) => () => withPgClient(async (client) => { 128 | if (setup) { 129 | if (typeof setup === 'function') { 130 | await setup(client); 131 | } else { 132 | await client.query(setup); 133 | } 134 | } 135 | 136 | const schemaOptions = Object.assign( 137 | { 138 | appendPlugins: [ 139 | require('postgraphile-plugin-connection-filter'), 140 | require('../index.js') 141 | ], 142 | showErrorStack: true, 143 | }, 144 | options, 145 | ); 146 | 147 | const schema = await createPostGraphileSchema(client, ['fulltext_test'], schemaOptions); 148 | return test({ 149 | schema, 150 | pgClient: client, 151 | }); 152 | }); 153 | 154 | const loadQuery = fn => readFilePromise(`${__dirname}/fixtures/queries/${fn}`, 'utf8'); 155 | 156 | exports.withRootDb = withRootDb; 157 | exports.withPrepopulatedDb = withPrepopulatedDb; 158 | exports.withPgClient = withPgClient; 159 | exports.withSchema = withSchema; 160 | exports.loadQuery = loadQuery; 161 | -------------------------------------------------------------------------------- /__tests__/printSchemaOrdered.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlipscombe/postgraphile-plugin-fulltext-filter/c780ff524da1ab691d843300bae34f60dfd83d83/__tests__/printSchemaOrdered.js -------------------------------------------------------------------------------- /__tests__/schema.sql: -------------------------------------------------------------------------------- 1 | drop schema if exists fulltext_test cascade; 2 | 3 | create schema fulltext_test; 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/PostgraphileFullTextFilterPlugin'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgraphile-plugin-fulltext-filter", 3 | "version": "1.0.0-beta.7", 4 | "description": "Full text searching on tsvector fields for use with postgraphile-plugin-connection-filter", 5 | "main": "index.js", 6 | "repository": { 7 | "url": "git+https://github.com/mlipscombe/postgraphile-plugin-fulltext-filter.git", 8 | "type": "git" 9 | }, 10 | "author": "Mark Lipscombe", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/mlipscombe/postgraphile-plugin-fulltext-filter.git" 14 | }, 15 | "scripts": { 16 | "test": "scripts/test jest -i", 17 | "lint": "eslint index.js src/**/*.js" 18 | }, 19 | "dependencies": { 20 | "graphile-build-pg": "^4.2.0", 21 | "pg-tsquery": "^6.4.2", 22 | "postgraphile-plugin-connection-filter": "^1.0.0-beta.28" 23 | }, 24 | "peerDependencies": { 25 | "postgraphile-core": "^4.2.0", 26 | "postgraphile-plugin-connection-filter": "^1.0.0-beta.28" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^5.10.0", 30 | "eslint-config-airbnb-base": "^13.1.0", 31 | "eslint-plugin-import": "^2.14.0", 32 | "graphql": "^14.0.2", 33 | "jest": "^23.6.0", 34 | "jest-junit": "^5.2.0", 35 | "pg": ">=6.1.0 <8", 36 | "postgraphile-core": "^4.2.0" 37 | }, 38 | "jest": { 39 | "testRegex": "__tests__/.*\\.test\\.js$", 40 | "collectCoverageFrom": [ 41 | "src/*.js", 42 | "index.js" 43 | ] 44 | }, 45 | "files": [ 46 | "src" 47 | ], 48 | "eslintConfig": { 49 | "parserOptions": { 50 | "ecmaFeatures": { 51 | "jsx": true 52 | } 53 | }, 54 | "extends": [ 55 | "airbnb-base" 56 | ], 57 | "env": { 58 | "jest": true 59 | }, 60 | "globals": { 61 | "expect": false 62 | }, 63 | "rules": { 64 | "import/no-unresolved": 0, 65 | "import/no-extraneous-dependencies": 0, 66 | "import/extensions": 0, 67 | "import/prefer-default-export": 0, 68 | "max-len": 0, 69 | "symbol-description": 0, 70 | "no-nested-ternary": 0, 71 | "no-alert": 0, 72 | "no-console": 0, 73 | "no-plusplus": 0, 74 | "no-restricted-globals": 0, 75 | "no-underscore-dangle": [ 76 | "error", 77 | { 78 | "allow": [ 79 | "_fields", 80 | "__fts_ranks" 81 | ] 82 | } 83 | ], 84 | "no-param-reassign": [ 85 | "error", 86 | { 87 | "props": false 88 | } 89 | ], 90 | "no-return-assign": [ 91 | "error", 92 | "except-parens" 93 | ], 94 | "class-methods-use-this": 0, 95 | "prefer-destructuring": [ 96 | "error", 97 | { 98 | "object": true, 99 | "array": false 100 | } 101 | ] 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [ -x ".env" ]; then 5 | set -a 6 | . ./.env 7 | set +a 8 | fi; 9 | 10 | if [ "$TEST_DATABASE_URL" == "" ]; then 11 | echo "ERROR: No test database configured; aborting" 12 | echo 13 | echo "To resolve this, ensure environmental variable TEST_DATABASE_URL is set" 14 | exit 1; 15 | fi; 16 | 17 | # Import latest schema (throw on error) 18 | psql -Xqv ON_ERROR_STOP=1 -f __tests__/schema.sql "$TEST_DATABASE_URL" 19 | echo "Database reset successfully ✅" 20 | 21 | # Now run the tests 22 | $@ 23 | -------------------------------------------------------------------------------- /src/PostgraphileFullTextFilterPlugin.js: -------------------------------------------------------------------------------- 1 | const tsquery = require('pg-tsquery'); 2 | const { omit } = require('graphile-build-pg'); 3 | 4 | module.exports = function PostGraphileFulltextFilterPlugin(builder) { 5 | builder.hook('inflection', (inflection, build) => build.extend(inflection, { 6 | fullTextScalarTypeName() { 7 | return 'FullText'; 8 | }, 9 | pgTsvRank(fieldName) { 10 | return this.camelCase(`${fieldName}-rank`); 11 | }, 12 | pgTsvOrderByColumnRankEnum(table, attr, ascending) { 13 | const columnName = attr.kind === 'procedure' 14 | ? attr.name.substr(table.name.length + 1) 15 | : this._columnName(attr, { skipRowId: true }); // eslint-disable-line no-underscore-dangle 16 | return this.constantCase(`${columnName}_rank_${ascending ? 'asc' : 'desc'}`); 17 | }, 18 | })); 19 | 20 | builder.hook('build', (build) => { 21 | const { 22 | pgIntrospectionResultsByKind: introspectionResultsByKind, 23 | pgRegisterGqlTypeByTypeId: registerGqlTypeByTypeId, 24 | pgRegisterGqlInputTypeByTypeId: registerGqlInputTypeByTypeId, 25 | graphql: { 26 | GraphQLScalarType, 27 | }, 28 | inflection, 29 | } = build; 30 | 31 | const tsvectorType = introspectionResultsByKind.type.find( 32 | t => t.name === 'tsvector', 33 | ); 34 | if (!tsvectorType) { 35 | throw new Error('Unable to find tsvector type through introspection.'); 36 | } 37 | 38 | const scalarName = inflection.fullTextScalarTypeName(); 39 | 40 | const GraphQLFullTextType = new GraphQLScalarType({ 41 | name: scalarName, 42 | serialize(value) { 43 | return value; 44 | }, 45 | parseValue(value) { 46 | return value; 47 | }, 48 | parseLiteral(lit) { 49 | return lit; 50 | }, 51 | }); 52 | 53 | registerGqlTypeByTypeId(tsvectorType.id, () => GraphQLFullTextType); 54 | registerGqlInputTypeByTypeId(tsvectorType.id, () => GraphQLFullTextType); 55 | 56 | return build.extend(build, { 57 | pgTsvType: tsvectorType, 58 | }); 59 | }); 60 | 61 | builder.hook('init', (_, build) => { 62 | const { 63 | addConnectionFilterOperator, 64 | pgSql: sql, 65 | pgGetGqlInputTypeByTypeIdAndModifier: getGqlInputTypeByTypeIdAndModifier, 66 | graphql: { 67 | GraphQLString, 68 | }, 69 | pgTsvType, 70 | } = build; 71 | 72 | if (!pgTsvType) { 73 | return build; 74 | } 75 | 76 | if (!(addConnectionFilterOperator instanceof Function)) { 77 | throw new Error('PostGraphileFulltextFilterPlugin requires PostGraphileConnectionFilterPlugin to be loaded before it.'); 78 | } 79 | 80 | const InputType = getGqlInputTypeByTypeIdAndModifier(pgTsvType.id, null); 81 | 82 | addConnectionFilterOperator( 83 | 'matches', 84 | 'Performs a full text search on the field.', 85 | () => GraphQLString, 86 | (identifier, val, input, fieldName, queryBuilder) => { 87 | const tsQueryString = tsquery(input); 88 | queryBuilder.__fts_ranks = queryBuilder.__fts_ranks || {}; 89 | queryBuilder.__fts_ranks[fieldName] = [identifier, tsQueryString]; 90 | return sql.query`${identifier} @@ to_tsquery(${sql.value(tsQueryString)})`; 91 | }, 92 | { 93 | allowedFieldTypes: [InputType.name], 94 | }, 95 | ); 96 | 97 | return (_, build); 98 | }); 99 | 100 | builder.hook('GraphQLObjectType:fields', (fields, build, context) => { 101 | const { 102 | pgIntrospectionResultsByKind: introspectionResultsByKind, 103 | graphql: { GraphQLFloat }, 104 | pgColumnFilter, 105 | pg2gql, 106 | pgSql: sql, 107 | inflection, 108 | pgTsvType, 109 | } = build; 110 | 111 | const { 112 | scope: { isPgRowType, isPgCompoundType, pgIntrospection: table }, 113 | fieldWithHooks, 114 | } = context; 115 | 116 | if ( 117 | !(isPgRowType || isPgCompoundType) 118 | || !table 119 | || table.kind !== 'class' 120 | || !pgTsvType 121 | ) { 122 | return fields; 123 | } 124 | 125 | const tableType = introspectionResultsByKind.type 126 | .filter(type => type.type === 'c' 127 | && type.namespaceId === table.namespaceId 128 | && type.classId === table.id)[0]; 129 | if (!tableType) { 130 | throw new Error('Could not determine the type of this table.'); 131 | } 132 | 133 | const tsvColumns = table.attributes 134 | .filter(attr => attr.typeId === pgTsvType.id) 135 | .filter(attr => pgColumnFilter(attr, build, context)) 136 | .filter(attr => !omit(attr, 'filter')); 137 | 138 | const tsvProcs = introspectionResultsByKind.procedure 139 | .filter(proc => proc.isStable) 140 | .filter(proc => proc.namespaceId === table.namespaceId) 141 | .filter(proc => proc.name.startsWith(`${table.name}_`)) 142 | .filter(proc => proc.argTypeIds.length > 0) 143 | .filter(proc => proc.argTypeIds[0] === tableType.id) 144 | .filter(proc => proc.returnTypeId === pgTsvType.id) 145 | .filter(proc => !omit(proc, 'filter')); 146 | 147 | if (tsvColumns.length === 0 && tsvProcs.length === 0) { 148 | return fields; 149 | } 150 | 151 | const newRankField = (baseFieldName, rankFieldName) => fieldWithHooks( 152 | rankFieldName, 153 | ({ addDataGenerator }) => { 154 | addDataGenerator(({ alias }) => ({ 155 | pgQuery: (queryBuilder) => { 156 | const { parentQueryBuilder } = queryBuilder; 157 | if ( 158 | !parentQueryBuilder 159 | || !parentQueryBuilder.__fts_ranks 160 | || !parentQueryBuilder.__fts_ranks[baseFieldName]) { 161 | return; 162 | } 163 | const [ 164 | identifier, 165 | tsQueryString, 166 | ] = parentQueryBuilder.__fts_ranks[baseFieldName]; 167 | queryBuilder.select( 168 | sql.fragment`ts_rank(${identifier}, to_tsquery(${sql.value(tsQueryString)}))`, 169 | alias, 170 | ); 171 | }, 172 | })); 173 | return { 174 | description: `Full-text search ranking when filtered by \`${baseFieldName}\`.`, 175 | type: GraphQLFloat, 176 | resolve: data => pg2gql(data[rankFieldName], GraphQLFloat), 177 | }; 178 | }, 179 | { 180 | isPgTSVRankField: true, 181 | }, 182 | ); 183 | 184 | const tsvFields = tsvColumns 185 | .reduce((memo, attr) => { 186 | const fieldName = inflection.column(attr); 187 | const rankFieldName = inflection.pgTsvRank(fieldName); 188 | memo[rankFieldName] = newRankField(fieldName, rankFieldName); // eslint-disable-line no-param-reassign 189 | 190 | return memo; 191 | }, {}); 192 | 193 | const tsvProcFields = tsvProcs 194 | .reduce((memo, proc) => { 195 | const psuedoColumnName = proc.name.substr(table.name.length + 1); 196 | const fieldName = inflection.computedColumn(psuedoColumnName, proc, table); 197 | const rankFieldName = inflection.pgTsvRank(fieldName); 198 | memo[rankFieldName] = newRankField(fieldName, rankFieldName); // eslint-disable-line no-param-reassign 199 | 200 | return memo; 201 | }, {}); 202 | 203 | return Object.assign({}, fields, tsvFields, tsvProcFields); 204 | }); 205 | 206 | builder.hook('GraphQLEnumType:values', (values, build, context) => { 207 | const { 208 | extend, 209 | pgSql: sql, 210 | pgColumnFilter, 211 | pgIntrospectionResultsByKind: introspectionResultsByKind, 212 | inflection, 213 | pgTsvType, 214 | } = build; 215 | 216 | const { 217 | scope: { 218 | isPgRowSortEnum, 219 | pgIntrospection: table, 220 | }, 221 | } = context; 222 | 223 | if (!isPgRowSortEnum || !table || table.kind !== 'class' || !pgTsvType) { 224 | return values; 225 | } 226 | 227 | const tableType = introspectionResultsByKind.type 228 | .filter(type => type.type === 'c' 229 | && type.namespaceId === table.namespaceId 230 | && type.classId === table.id)[0]; 231 | if (!tableType) { 232 | throw new Error('Could not determine the type of this table.'); 233 | } 234 | 235 | const tsvColumns = introspectionResultsByKind.attribute 236 | .filter(attr => attr.classId === table.id) 237 | .filter(attr => attr.typeId === pgTsvType.id); 238 | 239 | const tsvProcs = introspectionResultsByKind.procedure 240 | .filter(proc => proc.isStable) 241 | .filter(proc => proc.namespaceId === table.namespaceId) 242 | .filter(proc => proc.name.startsWith(`${table.name}_`)) 243 | .filter(proc => proc.argTypeIds.length === 1) 244 | .filter(proc => proc.argTypeIds[0] === tableType.id) 245 | .filter(proc => proc.returnTypeId === pgTsvType.id) 246 | .filter(proc => !omit(proc, 'order')); 247 | 248 | 249 | if (tsvColumns.length === 0 && tsvProcs.length === 0) { 250 | return values; 251 | } 252 | 253 | return extend( 254 | values, 255 | tsvColumns 256 | .concat(tsvProcs) 257 | .filter(attr => pgColumnFilter(attr, build, context)) 258 | .filter(attr => !omit(attr, 'order')) 259 | .reduce((memo, attr) => { 260 | const fieldName = attr.kind === 'procedure' 261 | ? inflection.computedColumn(attr.name.substr(table.name.length + 1), attr, table) 262 | : inflection.column(attr); 263 | const ascFieldName = inflection.pgTsvOrderByColumnRankEnum(table, attr, true); 264 | const descFieldName = inflection.pgTsvOrderByColumnRankEnum(table, attr, false); 265 | 266 | const findExpr = ({ queryBuilder }) => { 267 | if (!queryBuilder.__fts_ranks || !queryBuilder.__fts_ranks[fieldName]) { 268 | return sql.fragment`1`; 269 | } 270 | const [ 271 | identifier, 272 | tsQueryString, 273 | ] = queryBuilder.__fts_ranks[fieldName]; 274 | return sql.fragment`ts_rank(${identifier}, to_tsquery(${sql.value(tsQueryString)}))`; 275 | }; 276 | 277 | memo[ascFieldName] = { // eslint-disable-line no-param-reassign 278 | value: { 279 | alias: `${ascFieldName.toLowerCase()}`, 280 | specs: [[findExpr, true]], 281 | }, 282 | }; 283 | memo[descFieldName] = { // eslint-disable-line no-param-reassign 284 | value: { 285 | alias: `${descFieldName.toLowerCase()}`, 286 | specs: [[findExpr, false]], 287 | }, 288 | }; 289 | 290 | return memo; 291 | }, {}), 292 | `Adding TSV rank columns for sorting on table '${table.name}'`, 293 | ); 294 | }); 295 | }; 296 | --------------------------------------------------------------------------------