├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── zombodb.test.js.snap ├── helpers.js ├── printSchemaOrdered.js ├── schema.sql └── zombodb.test.js ├── index.js ├── package.json ├── scripts └── test ├── src └── PostgraphileZomboDBPlugin.js └── yarn.lock /.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: docker.elastic.co/elasticsearch/elasticsearch:6.5.4 12 | environment: 13 | discovery.type: single-node 14 | - image: mlipscombe/postgres-zombodb:10-1.0.0 15 | environment: 16 | POSTGRES_USER: circleci 17 | POSTGRES_DB: circle_test 18 | 19 | # Specify service dependencies here if necessary 20 | # CircleCI maintains a library of pre-built images 21 | # documented at https://circleci.com/docs/2.0/circleci-images/ 22 | # - image: circleci/mongo:3.4.4 23 | 24 | working_directory: ~/repo 25 | 26 | steps: 27 | - checkout 28 | 29 | # Download and cache dependencies 30 | - restore_cache: 31 | keys: 32 | - v1-dependencies-{{ checksum "package.json" }} 33 | # fallback to using the latest cache if no exact match is found 34 | - v1-dependencies- 35 | 36 | - run: 37 | name: Installing dependencies 38 | command: | 39 | yarn install 40 | sudo apt-get install postgresql-client 41 | 42 | - save_cache: 43 | paths: 44 | - node_modules 45 | key: v1-dependencies-{{ checksum "package.json" }} 46 | 47 | - run: 48 | name: Waiting for Postgres to be ready 49 | command: | 50 | for i in `seq 1 10`; 51 | do 52 | nc -z localhost 5432 && echo Success && exit 0 53 | echo -n . 54 | sleep 1 55 | done 56 | echo Failed waiting for Postgres && exit 1 57 | - run: 58 | name: Waiting for Elasticsearch to be ready 59 | command: | 60 | for i in `seq 1 10`; 61 | do 62 | nc -z localhost 9200 && echo Success && exit 0 63 | echo -n . 64 | sleep 1 65 | done 66 | echo Failed waiting for Elasticsearch && exit 1 67 | 68 | # run tests! 69 | - run: 70 | name: Run linter 71 | command: yarn lint --format junit -o reports/junit/js-lint-results.xml 72 | 73 | - run: 74 | name: Run unit tests 75 | environment: 76 | TEST_DATABASE_URL: postgres://circleci@localhost:5432/circle_test 77 | JEST_JUNIT_OUTPUT: 'reports/junit/js-test-results.xml' 78 | command: yarn test -- --ci --testResultsProcessor="jest-junit" 79 | 80 | - store_test_results: 81 | path: reports/junit 82 | 83 | - store_artifacts: 84 | path: reports/junit 85 | -------------------------------------------------------------------------------- /.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-zombodb.svg)](https://www.npmjs.com/package/postgraphile-plugin-zombodb) 2 | [![CircleCI](https://circleci.com/gh/mlipscombe/postgraphile-plugin-zombodb/tree/master.svg?style=svg)](https://circleci.com/gh/mlipscombe/postgraphile-plugin-zombodb/tree/master) 3 | 4 | # postgraphile-plugin-zombodb 5 | This plugin implements a full text search operator for tables that have a 6 | [ZomboDB](https://github.com/zombodb/zombodb) index, using [Elasticsearch](https://www.elastic.co/). 7 | 8 | ## Getting Started 9 | 10 | ### CLI 11 | 12 | ``` bash 13 | postgraphile --append-plugins postgraphile-plugin-zombodb 14 | ``` 15 | 16 | See [here](https://www.graphile.org/postgraphile/extending/#loading-additional-plugins) for 17 | more information about loading plugins with PostGraphile. 18 | 19 | ### Library 20 | 21 | ``` js 22 | const express = require('express'); 23 | const { postgraphile } = require('postgraphile'); 24 | const PostGraphileZomboDBPlugin = require('postgraphile-plugin-zombodb'); 25 | 26 | const app = express(); 27 | 28 | app.use( 29 | postgraphile(pgConfig, schema, { 30 | appendPlugins: [ 31 | PostGraphileZomboDBPlugin, 32 | ], 33 | }) 34 | ); 35 | 36 | app.listen(5000); 37 | ``` 38 | 39 | ## Schema 40 | 41 | The plugin discovers all `ZomboDB` indexes and adds a `search` input argument for 42 | each table with an index. For help with getting started with ZomboDB, check out the [tutorial](https://github.com/zombodb/zombodb/blob/master/TUTORIAL.md). 43 | 44 | ## Searching 45 | 46 | The plugin passes the search string directly to the ZomboDB extension. See ZomboDB's [Query DSL documentation](https://github.com/zombodb/zombodb/blob/master/QUERY-DSL.md) for how to structure queries. 47 | 48 | ## Scoring 49 | 50 | A `Float` score column will be automatically added to the GraphQL type for each indexed table, named `_score` by default. 51 | 52 | This score field can be used for ordering and is automatically added to the orderBy 53 | enum for the table. 54 | 55 | ## Examples 56 | 57 | ``` graphql 58 | query { 59 | allPosts( 60 | search: { 61 | query: "+cat and +dog" 62 | minScore: 0.5 63 | } 64 | orderBy: _SCORE_DESC 65 | }) { 66 | ... 67 | _score 68 | } 69 | } 70 | ``` 71 | 72 | ## To Do 73 | 74 | * This plugin does not yet map `limit`/`offset` and `order by` parameters into 75 | [ZomboDB's query DSL](https://github.com/zombodb/zombodb/blob/master/QUERY-DSL.md), 76 | and so searches on huge tables may not be particularly performant. 77 | * Match highlighting. 78 | * Structured queries. 79 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/zombodb.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic functionality works 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 | "Product", 16 | ], 17 | }, 18 | "_mutationType": "Mutation", 19 | "_possibleTypeMap": Object {}, 20 | "_queryType": "Query", 21 | "_subscriptionType": undefined, 22 | "_typeMap": Object { 23 | "BigInt": "BigInt", 24 | "Boolean": "Boolean", 25 | "CreateProductInput": "CreateProductInput", 26 | "CreateProductPayload": "CreateProductPayload", 27 | "Cursor": "Cursor", 28 | "Date": "Date", 29 | "DeleteProductByIdInput": "DeleteProductByIdInput", 30 | "DeleteProductInput": "DeleteProductInput", 31 | "DeleteProductPayload": "DeleteProductPayload", 32 | "Float": "Float", 33 | "Fulltext": "Fulltext", 34 | "ID": "ID", 35 | "Int": "Int", 36 | "Mutation": "Mutation", 37 | "Node": "Node", 38 | "PageInfo": "PageInfo", 39 | "Product": "Product", 40 | "ProductCondition": "ProductCondition", 41 | "ProductInput": "ProductInput", 42 | "ProductPatch": "ProductPatch", 43 | "ProductsConnection": "ProductsConnection", 44 | "ProductsEdge": "ProductsEdge", 45 | "ProductsOrderBy": "ProductsOrderBy", 46 | "Query": "Query", 47 | "SearchQuery": "SearchQuery", 48 | "String": "String", 49 | "UpdateProductByIdInput": "UpdateProductByIdInput", 50 | "UpdateProductInput": "UpdateProductInput", 51 | "UpdateProductPayload": "UpdateProductPayload", 52 | "__Directive": "__Directive", 53 | "__DirectiveLocation": "__DirectiveLocation", 54 | "__EnumValue": "__EnumValue", 55 | "__Field": "__Field", 56 | "__InputValue": "__InputValue", 57 | "__Schema": "__Schema", 58 | "__Type": "__Type", 59 | "__TypeKind": "__TypeKind", 60 | }, 61 | "astNode": undefined, 62 | "extensionASTNodes": undefined, 63 | } 64 | `; 65 | 66 | exports[`query _score field without search works 1`] = ` 67 | GraphQLSchema { 68 | "__allowedLegacyNames": Array [], 69 | "__validationErrors": undefined, 70 | "_directives": Array [ 71 | "@include", 72 | "@skip", 73 | "@deprecated", 74 | ], 75 | "_implementations": Object { 76 | "Node": Array [ 77 | "Query", 78 | "Product", 79 | "Review", 80 | ], 81 | }, 82 | "_mutationType": "Mutation", 83 | "_possibleTypeMap": Object {}, 84 | "_queryType": "Query", 85 | "_subscriptionType": undefined, 86 | "_typeMap": Object { 87 | "BigInt": "BigInt", 88 | "Boolean": "Boolean", 89 | "CreateProductInput": "CreateProductInput", 90 | "CreateProductPayload": "CreateProductPayload", 91 | "CreateReviewInput": "CreateReviewInput", 92 | "CreateReviewPayload": "CreateReviewPayload", 93 | "Cursor": "Cursor", 94 | "Date": "Date", 95 | "DeleteProductByIdInput": "DeleteProductByIdInput", 96 | "DeleteProductInput": "DeleteProductInput", 97 | "DeleteProductPayload": "DeleteProductPayload", 98 | "DeleteReviewByIdInput": "DeleteReviewByIdInput", 99 | "DeleteReviewInput": "DeleteReviewInput", 100 | "DeleteReviewPayload": "DeleteReviewPayload", 101 | "Float": "Float", 102 | "Fulltext": "Fulltext", 103 | "ID": "ID", 104 | "Int": "Int", 105 | "Mutation": "Mutation", 106 | "Node": "Node", 107 | "PageInfo": "PageInfo", 108 | "Product": "Product", 109 | "ProductCondition": "ProductCondition", 110 | "ProductInput": "ProductInput", 111 | "ProductPatch": "ProductPatch", 112 | "ProductsConnection": "ProductsConnection", 113 | "ProductsEdge": "ProductsEdge", 114 | "ProductsOrderBy": "ProductsOrderBy", 115 | "Query": "Query", 116 | "Review": "Review", 117 | "ReviewCondition": "ReviewCondition", 118 | "ReviewInput": "ReviewInput", 119 | "ReviewPatch": "ReviewPatch", 120 | "ReviewsConnection": "ReviewsConnection", 121 | "ReviewsEdge": "ReviewsEdge", 122 | "ReviewsOrderBy": "ReviewsOrderBy", 123 | "SearchQuery": "SearchQuery", 124 | "String": "String", 125 | "UpdateProductByIdInput": "UpdateProductByIdInput", 126 | "UpdateProductInput": "UpdateProductInput", 127 | "UpdateProductPayload": "UpdateProductPayload", 128 | "UpdateReviewByIdInput": "UpdateReviewByIdInput", 129 | "UpdateReviewInput": "UpdateReviewInput", 130 | "UpdateReviewPayload": "UpdateReviewPayload", 131 | "__Directive": "__Directive", 132 | "__DirectiveLocation": "__DirectiveLocation", 133 | "__EnumValue": "__EnumValue", 134 | "__Field": "__Field", 135 | "__InputValue": "__InputValue", 136 | "__Schema": "__Schema", 137 | "__Type": "__Type", 138 | "__TypeKind": "__TypeKind", 139 | }, 140 | "astNode": undefined, 141 | "extensionASTNodes": undefined, 142 | } 143 | `; 144 | 145 | exports[`query without any zombodb search works on table that has zombodb index 1`] = ` 146 | GraphQLSchema { 147 | "__allowedLegacyNames": Array [], 148 | "__validationErrors": undefined, 149 | "_directives": Array [ 150 | "@include", 151 | "@skip", 152 | "@deprecated", 153 | ], 154 | "_implementations": Object { 155 | "Node": Array [ 156 | "Query", 157 | "Product", 158 | ], 159 | }, 160 | "_mutationType": "Mutation", 161 | "_possibleTypeMap": Object {}, 162 | "_queryType": "Query", 163 | "_subscriptionType": undefined, 164 | "_typeMap": Object { 165 | "BigInt": "BigInt", 166 | "Boolean": "Boolean", 167 | "CreateProductInput": "CreateProductInput", 168 | "CreateProductPayload": "CreateProductPayload", 169 | "Cursor": "Cursor", 170 | "Date": "Date", 171 | "DeleteProductByIdInput": "DeleteProductByIdInput", 172 | "DeleteProductInput": "DeleteProductInput", 173 | "DeleteProductPayload": "DeleteProductPayload", 174 | "Float": "Float", 175 | "Fulltext": "Fulltext", 176 | "ID": "ID", 177 | "Int": "Int", 178 | "Mutation": "Mutation", 179 | "Node": "Node", 180 | "PageInfo": "PageInfo", 181 | "Product": "Product", 182 | "ProductCondition": "ProductCondition", 183 | "ProductInput": "ProductInput", 184 | "ProductPatch": "ProductPatch", 185 | "ProductsConnection": "ProductsConnection", 186 | "ProductsEdge": "ProductsEdge", 187 | "ProductsOrderBy": "ProductsOrderBy", 188 | "Query": "Query", 189 | "SearchQuery": "SearchQuery", 190 | "String": "String", 191 | "UpdateProductByIdInput": "UpdateProductByIdInput", 192 | "UpdateProductInput": "UpdateProductInput", 193 | "UpdateProductPayload": "UpdateProductPayload", 194 | "__Directive": "__Directive", 195 | "__DirectiveLocation": "__DirectiveLocation", 196 | "__EnumValue": "__EnumValue", 197 | "__Field": "__Field", 198 | "__InputValue": "__InputValue", 199 | "__Schema": "__Schema", 200 | "__Type": "__Type", 201 | "__TypeKind": "__TypeKind", 202 | }, 203 | "astNode": undefined, 204 | "extensionASTNodes": undefined, 205 | } 206 | `; 207 | 208 | exports[`sort does not cause an error 1`] = ` 209 | GraphQLSchema { 210 | "__allowedLegacyNames": Array [], 211 | "__validationErrors": undefined, 212 | "_directives": Array [ 213 | "@include", 214 | "@skip", 215 | "@deprecated", 216 | ], 217 | "_implementations": Object { 218 | "Node": Array [ 219 | "Query", 220 | "Country", 221 | ], 222 | }, 223 | "_mutationType": "Mutation", 224 | "_possibleTypeMap": Object {}, 225 | "_queryType": "Query", 226 | "_subscriptionType": undefined, 227 | "_typeMap": Object { 228 | "BigInt": "BigInt", 229 | "Boolean": "Boolean", 230 | "CountriesConnection": "CountriesConnection", 231 | "CountriesEdge": "CountriesEdge", 232 | "CountriesOrderBy": "CountriesOrderBy", 233 | "Country": "Country", 234 | "CountryCondition": "CountryCondition", 235 | "CountryInput": "CountryInput", 236 | "CountryPatch": "CountryPatch", 237 | "CreateCountryInput": "CreateCountryInput", 238 | "CreateCountryPayload": "CreateCountryPayload", 239 | "Cursor": "Cursor", 240 | "DeleteCountryByIdInput": "DeleteCountryByIdInput", 241 | "DeleteCountryInput": "DeleteCountryInput", 242 | "DeleteCountryPayload": "DeleteCountryPayload", 243 | "Float": "Float", 244 | "ID": "ID", 245 | "Int": "Int", 246 | "Mutation": "Mutation", 247 | "Node": "Node", 248 | "PageInfo": "PageInfo", 249 | "Query": "Query", 250 | "SearchQuery": "SearchQuery", 251 | "String": "String", 252 | "UpdateCountryByIdInput": "UpdateCountryByIdInput", 253 | "UpdateCountryInput": "UpdateCountryInput", 254 | "UpdateCountryPayload": "UpdateCountryPayload", 255 | "__Directive": "__Directive", 256 | "__DirectiveLocation": "__DirectiveLocation", 257 | "__EnumValue": "__EnumValue", 258 | "__Field": "__Field", 259 | "__InputValue": "__InputValue", 260 | "__Schema": "__Schema", 261 | "__Type": "__Type", 262 | "__TypeKind": "__TypeKind", 263 | }, 264 | "astNode": undefined, 265 | "extensionASTNodes": undefined, 266 | } 267 | `; 268 | -------------------------------------------------------------------------------- /__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 withPgClient = async (url, fn) => { 20 | if (!fn) { 21 | fn = url; 22 | url = process.env.TEST_DATABASE_URL; 23 | } 24 | const pgPool = new pg.Pool(pgConnectionString.parse(url)); 25 | let client; 26 | try { 27 | client = await pgPool.connect(); 28 | await client.query('begin'); 29 | await client.query('set local timezone to \'+04:00\''); 30 | const result = await fn(client); 31 | await client.query('rollback'); 32 | return result; 33 | } finally { 34 | try { 35 | await client.release(); 36 | } catch (e) { 37 | console.error('Error releasing pgClient', e); 38 | } 39 | await pgPool.end(); 40 | } 41 | }; 42 | 43 | const withDbFromUrl = async (url, fn) => withPgClient(url, async (client) => { 44 | try { 45 | await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE;'); 46 | return fn(client); 47 | } finally { 48 | await client.query('COMMIT;'); 49 | } 50 | }); 51 | 52 | 53 | const withRootDb = fn => withDbFromUrl(process.env.TEST_DATABASE_URL, fn); 54 | 55 | let prepopulatedDBKeepalive; 56 | 57 | const populateDatabase = async (client) => { 58 | await client.query(await readFilePromise(`${__dirname}/data.sql`, 'utf8')); 59 | return {}; 60 | }; 61 | 62 | const withPrepopulatedDb = async (fn) => { 63 | if (!prepopulatedDBKeepalive) { 64 | throw new Error('You must call setup and teardown to use this'); 65 | } 66 | const { client, vars } = prepopulatedDBKeepalive; 67 | if (!vars) { 68 | throw new Error('No prepopulated vars'); 69 | } 70 | let err; 71 | try { 72 | await fn(client, vars); 73 | } catch (e) { 74 | err = e; 75 | } 76 | try { 77 | await client.query('ROLLBACK TO SAVEPOINT pristine;'); 78 | } catch (e) { 79 | err = err || e; 80 | console.error('ERROR ROLLING BACK', e.message); // eslint-disable-line no-console 81 | } 82 | if (err) { 83 | throw err; 84 | } 85 | }; 86 | 87 | withPrepopulatedDb.setup = (done) => { 88 | if (prepopulatedDBKeepalive) { 89 | throw new Error("There's already a prepopulated DB running"); 90 | } 91 | let res; 92 | let rej; 93 | prepopulatedDBKeepalive = new Promise((resolve, reject) => { 94 | res = resolve; 95 | rej = reject; 96 | }); 97 | prepopulatedDBKeepalive.resolve = res; 98 | prepopulatedDBKeepalive.reject = rej; 99 | withRootDb(async (client) => { 100 | prepopulatedDBKeepalive.client = client; 101 | try { 102 | prepopulatedDBKeepalive.vars = await populateDatabase(client); 103 | } catch (e) { 104 | console.error('FAILED TO PREPOPULATE DB!', e.message); // eslint-disable-line no-console 105 | return done(e); 106 | } 107 | await client.query('SAVEPOINT pristine;'); 108 | done(); 109 | return prepopulatedDBKeepalive; 110 | }); 111 | }; 112 | 113 | withPrepopulatedDb.teardown = () => { 114 | if (!prepopulatedDBKeepalive) { 115 | throw new Error('Cannot tear down null!'); 116 | } 117 | prepopulatedDBKeepalive.resolve(); // Release DB transaction 118 | prepopulatedDBKeepalive = null; 119 | }; 120 | 121 | const withSchema = ({ 122 | setup, 123 | test, 124 | options = {}, 125 | }) => () => withPgClient(async (client) => { 126 | if (setup) { 127 | if (typeof setup === 'function') { 128 | await setup(client); 129 | } else { 130 | await client.query(setup); 131 | } 132 | } 133 | 134 | const schemaOptions = Object.assign( 135 | { 136 | appendPlugins: [ 137 | require('../index.js') 138 | ], 139 | showErrorStack: true, 140 | }, 141 | options, 142 | ); 143 | 144 | const schema = await createPostGraphileSchema(client, ['zombodb_test'], schemaOptions); 145 | return test({ 146 | schema, 147 | pgClient: client, 148 | }); 149 | }); 150 | 151 | const loadQuery = fn => readFilePromise(`${__dirname}/fixtures/queries/${fn}`, 'utf8'); 152 | 153 | exports.withRootDb = withRootDb; 154 | exports.withPrepopulatedDb = withPrepopulatedDb; 155 | exports.withPgClient = withPgClient; 156 | exports.withSchema = withSchema; 157 | exports.loadQuery = loadQuery; 158 | -------------------------------------------------------------------------------- /__tests__/printSchemaOrdered.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlipscombe/postgraphile-plugin-zombodb/728f48133a43a7d15efff0168189f57f3d3e4491/__tests__/printSchemaOrdered.js -------------------------------------------------------------------------------- /__tests__/schema.sql: -------------------------------------------------------------------------------- 1 | drop schema if exists zombodb_test cascade; 2 | 3 | create schema zombodb_test; 4 | 5 | create extension if not exists "zombodb"; 6 | -------------------------------------------------------------------------------- /__tests__/zombodb.test.js: -------------------------------------------------------------------------------- 1 | const { graphql } = require('graphql'); 2 | const { withSchema } = require('./helpers'); 3 | 4 | // example schema taken from https://github.com/zombodb/zombodb/blob/master/TUTORIAL.md 5 | 6 | test( 7 | 'basic functionality works', 8 | withSchema({ 9 | setup: ` 10 | CREATE TABLE zombodb_test.products ( 11 | id SERIAL8 NOT NULL PRIMARY KEY, 12 | name text NOT NULL, 13 | keywords varchar(64)[], 14 | short_summary text, 15 | long_description zdb.fulltext, 16 | price bigint, 17 | inventory_count integer, 18 | discontinued boolean default false, 19 | availability_date date 20 | ); 21 | 22 | CREATE INDEX idxproducts 23 | ON zombodb_test.products 24 | USING zombodb ((zombodb_test.products.*)) 25 | WITH (url='${process.env.TEST_ELASTICSEARCH_URL || 'http://localhost:9200/'}'); 26 | 27 | insert into zombodb_test.products values 28 | (1, 'Magical Widget', '{magical,widget,round}', 'A widget that is quite magical', 'Magical Widgets come from the land of Magicville and are capable of things you can''t imagine', 9900, 42, 'f', '2015-08-31'), 29 | (2, 'Baseball', '{baseball,sports,round}', 'It''s a baseball', 'Throw it at a person with a big wooden stick and hope they don''t hit it', 1249, 2, 'f', '2015-08-21'), 30 | (3, 'Telephone', '{communication,primitive,"alexander graham bell"}', 'A device to enable long-distance communications', 'Use this to call your friends and family and be annoyed by telemarketers. Long-distance charges may apply', 1899, 200, 'f', '2015-08-11'), 31 | (4, 'Box', '{wooden,box,"negative space",square}', 'Just an empty box made of wood', 'A wooden container that will eventually rot away. Put stuff it in (but not a cat).', 17000,0,'t','2015-07-01'); 32 | `, 33 | test: async ({ schema, pgClient }) => { 34 | expect(schema).toMatchSnapshot(); 35 | 36 | const result = await graphql(schema, ` 37 | query { 38 | allProducts( 39 | search: { 40 | query: "sports box" 41 | minScore: 0 42 | } 43 | orderBy: [_SCORE_DESC, NAME_ASC] 44 | ) { 45 | nodes { 46 | id 47 | name 48 | _score 49 | } 50 | } 51 | } 52 | `, null, { pgClient }); 53 | 54 | expect(result).not.toHaveProperty('errors'); 55 | const { nodes } = result.data.allProducts; 56 | expect(nodes).toHaveLength(2); 57 | }, 58 | }), 59 | ); 60 | 61 | test( 62 | 'query _score field without search works', 63 | withSchema({ 64 | setup: ` 65 | CREATE TABLE zombodb_test.products ( 66 | id SERIAL8 NOT NULL PRIMARY KEY, 67 | name text NOT NULL, 68 | keywords varchar(64)[], 69 | short_summary text, 70 | long_description zdb.fulltext, 71 | price bigint, 72 | inventory_count integer, 73 | discontinued boolean default false, 74 | availability_date date 75 | ); 76 | 77 | create table zombodb_test.reviews ( 78 | id serial not null primary key, 79 | product_id int8 not null references zombodb_test.products (id), 80 | review text 81 | ); 82 | 83 | CREATE INDEX idxproducts 84 | ON zombodb_test.products 85 | USING zombodb ((zombodb_test.products.*)) 86 | WITH (url='${process.env.TEST_ELASTICSEARCH_URL || 'http://localhost:9200/'}'); 87 | 88 | insert into zombodb_test.products values 89 | (1, 'Magical Widget', '{magical,widget,round}', 'A widget that is quite magical', 'Magical Widgets come from the land of Magicville and are capable of things you can''t imagine', 9900, 42, 'f', '2015-08-31'), 90 | (2, 'Baseball', '{baseball,sports,round}', 'It''s a baseball', 'Throw it at a person with a big wooden stick and hope they don''t hit it', 1249, 2, 'f', '2015-08-21'), 91 | (3, 'Telephone', '{communication,primitive,"alexander graham bell"}', 'A device to enable long-distance communications', 'Use this to call your friends and family and be annoyed by telemarketers. Long-distance charges may apply', 1899, 200, 'f', '2015-08-11'), 92 | (4, 'Box', '{wooden,box,"negative space",square}', 'Just an empty box made of wood', 'A wooden container that will eventually rot away. Put stuff it in (but not a cat).', 17000,0,'t','2015-07-01'); 93 | 94 | insert into zombodb_test.reviews values 95 | (1, 2, 'it is great'); 96 | `, 97 | test: async ({ schema, pgClient }) => { 98 | expect(schema).toMatchSnapshot(); 99 | 100 | const result = await graphql(schema, ` 101 | query { 102 | allProducts { 103 | nodes { 104 | id 105 | name 106 | _score 107 | reviewsByProductId { 108 | nodes { 109 | id 110 | review 111 | } 112 | } 113 | } 114 | } 115 | } 116 | `, null, { pgClient }); 117 | 118 | expect(result).not.toHaveProperty('errors'); 119 | const { nodes } = result.data.allProducts; 120 | expect(nodes).toHaveLength(4); 121 | }, 122 | }), 123 | ); 124 | 125 | test( 126 | 'query without any zombodb search works on table that has zombodb index', 127 | withSchema({ 128 | setup: ` 129 | CREATE TABLE zombodb_test.products ( 130 | id SERIAL8 NOT NULL PRIMARY KEY, 131 | name text NOT NULL, 132 | keywords varchar(64)[], 133 | short_summary text, 134 | long_description zdb.fulltext, 135 | price bigint, 136 | inventory_count integer, 137 | discontinued boolean default false, 138 | availability_date date 139 | ); 140 | 141 | CREATE INDEX idxproducts 142 | ON zombodb_test.products 143 | USING zombodb ((zombodb_test.products.*)) 144 | WITH (url='${process.env.TEST_ELASTICSEARCH_URL || 'http://localhost:9200/'}'); 145 | 146 | insert into zombodb_test.products values 147 | (1, 'Magical Widget', '{magical,widget,round}', 'A widget that is quite magical', 'Magical Widgets come from the land of Magicville and are capable of things you can''t imagine', 9900, 42, 'f', '2015-08-31'), 148 | (2, 'Baseball', '{baseball,sports,round}', 'It''s a baseball', 'Throw it at a person with a big wooden stick and hope they don''t hit it', 1249, 2, 'f', '2015-08-21'), 149 | (3, 'Telephone', '{communication,primitive,"alexander graham bell"}', 'A device to enable long-distance communications', 'Use this to call your friends and family and be annoyed by telemarketers. Long-distance charges may apply', 1899, 200, 'f', '2015-08-11'), 150 | (4, 'Box', '{wooden,box,"negative space",square}', 'Just an empty box made of wood', 'A wooden container that will eventually rot away. Put stuff it in (but not a cat).', 17000,0,'t','2015-07-01'); 151 | `, 152 | test: async ({ schema, pgClient }) => { 153 | expect(schema).toMatchSnapshot(); 154 | 155 | const result = await graphql(schema, ` 156 | query { 157 | allProducts { 158 | nodes { 159 | id 160 | name 161 | } 162 | } 163 | } 164 | `, null, { pgClient }); 165 | 166 | expect(result).not.toHaveProperty('errors'); 167 | const { nodes } = result.data.allProducts; 168 | expect(nodes).toHaveLength(4); 169 | }, 170 | }), 171 | ); 172 | 173 | test( 174 | 'sort does not cause an error', 175 | withSchema({ 176 | setup: ` 177 | CREATE TABLE zombodb_test.country ( 178 | id SERIAL8 NOT NULL PRIMARY KEY, 179 | name text NOT NULL, 180 | active boolean not null default 't' 181 | ); 182 | 183 | CREATE INDEX idx_countries 184 | ON zombodb_test.country 185 | USING zombodb ((zombodb_test.country.*)) 186 | WITH (url='${process.env.TEST_ELASTICSEARCH_URL || 'http://localhost:9200/'}'); 187 | 188 | insert into zombodb_test.country (name) values 189 | ('United States'), 190 | ('United Kingdom'), 191 | ('Australia'), 192 | ('Argentina'); 193 | `, 194 | test: async ({ schema, pgClient }) => { 195 | expect(schema).toMatchSnapshot(); 196 | 197 | const result = await graphql(schema, ` 198 | query { 199 | allCountries( 200 | search: {query: "australia"}, 201 | condition: { active: true } 202 | orderBy: [_SCORE_DESC, NAME_ASC] 203 | ) { 204 | nodes { 205 | id 206 | name 207 | _score 208 | } 209 | } 210 | } 211 | `, null, { pgClient }); 212 | 213 | expect(result).not.toHaveProperty('errors'); 214 | const { nodes } = result.data.allCountries; 215 | expect(nodes).toHaveLength(1); 216 | }, 217 | }), 218 | ); 219 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/PostgraphileZomboDBPlugin'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgraphile-plugin-zombodb", 3 | "version": "1.0.0-beta.5", 4 | "description": "Full text searching using ZomboDB indexes", 5 | "main": "index.js", 6 | "repository": { 7 | "url": "git+https://github.com/mlipscombe/postgraphile-plugin-zombodb.git", 8 | "type": "git" 9 | }, 10 | "author": "Mark Lipscombe", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/mlipscombe/postgraphile-plugin-zombodb.git" 14 | }, 15 | "scripts": { 16 | "test": "scripts/test jest -i", 17 | "lint": "eslint index.js src/**/*.js" 18 | }, 19 | "peerDependencies": { 20 | "graphile-build-pg": "^4.4.0-beta.10" 21 | }, 22 | "devDependencies": { 23 | "eslint": "^5.10.0", 24 | "eslint-config-airbnb-base": "^13.1.0", 25 | "eslint-config-prettier": "^4.0.0", 26 | "eslint-plugin-import": "^2.14.0", 27 | "eslint-plugin-prettier": "^3.0.1", 28 | "graphile-build-pg": "^4.4.0-beta.10", 29 | "graphql": "^14.0.2", 30 | "jest": "^23.6.0", 31 | "jest-junit": "^5.2.0", 32 | "pg": ">=6.1.0 <8", 33 | "postgraphile-core": "^4.4.0-beta.10", 34 | "prettier": "^1.16.4" 35 | }, 36 | "resolutions": { 37 | "graphql": "^14.0.2" 38 | }, 39 | "jest": { 40 | "testRegex": "__tests__/.*\\.test\\.js$", 41 | "collectCoverageFrom": [ 42 | "src/*.js", 43 | "index.js" 44 | ] 45 | }, 46 | "files": [ 47 | "src" 48 | ], 49 | "prettier": { 50 | "trailingComma": "all", 51 | "semi": true, 52 | "singleQuote": true, 53 | "arrowParens": "always" 54 | }, 55 | "eslintConfig": { 56 | "extends": [ 57 | "airbnb-base", 58 | "prettier" 59 | ], 60 | "plugins": [ 61 | "prettier" 62 | ], 63 | "env": { 64 | "jest": true 65 | }, 66 | "globals": { 67 | "expect": false 68 | }, 69 | "rules": { 70 | "prettier/prettier": "error", 71 | "no-sequences": 0 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /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/PostgraphileZomboDBPlugin.js: -------------------------------------------------------------------------------- 1 | module.exports = function PostGraphileZomboDBPlugin( 2 | builder, 3 | { zomboInputFieldName = 'search', zomboScoreFieldName = '_score' } = {}, 4 | ) { 5 | builder.hook('inflection', (inflection, build) => 6 | build.extend(inflection, { 7 | pgZomboInputField() { 8 | return zomboInputFieldName; 9 | }, 10 | pgZomboScoreField() { 11 | return zomboScoreFieldName; 12 | }, 13 | pgZomboFilterType() { 14 | return 'SearchQuery'; 15 | }, 16 | pgOrderByScoreAscEnum() { 17 | return this.constantCase(`${this.pgZomboScoreField()}_asc`); 18 | }, 19 | pgOrderByScoreDescEnum() { 20 | return this.constantCase(`${this.pgZomboScoreField()}_desc`); 21 | }, 22 | }), 23 | ); 24 | 25 | builder.hook('build', (build) => { 26 | const { 27 | pgIntrospectionResultsByKind: introspectionResultsByKind, 28 | pgOmit: omit, 29 | } = build; 30 | const pgZomboTables = {}; 31 | 32 | const zomboExtension = introspectionResultsByKind.extension.find( 33 | (e) => e.name === 'zombodb', 34 | ); 35 | if (!zomboExtension) { 36 | return build; 37 | } 38 | 39 | introspectionResultsByKind.index.forEach((idx) => { 40 | if (idx.indexType !== 'zombodb') return; 41 | if (omit(idx, 'zombodb')) return; 42 | if (!idx.class.namespace) return; 43 | 44 | const table = idx.class; 45 | if (!table.namespace || !table.isSelectable || omit(idx, 'read')) { 46 | return; 47 | } 48 | 49 | pgZomboTables[table.id] = idx; 50 | }); 51 | 52 | return build.extend(build, { 53 | pgZomboTables, 54 | }); 55 | }); 56 | 57 | builder.hook('init', (_, build) => { 58 | const { 59 | newWithHooks, 60 | graphql: { 61 | GraphQLInputObjectType, 62 | GraphQLString, 63 | GraphQLNonNull, 64 | GraphQLFloat, 65 | }, 66 | inflection, 67 | pgZomboTables, 68 | } = build; 69 | 70 | if (!pgZomboTables) { 71 | return _, build; 72 | } 73 | 74 | newWithHooks( 75 | GraphQLInputObjectType, 76 | { 77 | description: 78 | 'A full text search filter to be used against a collection.', 79 | name: inflection.pgZomboFilterType(), 80 | fields: { 81 | query: { 82 | description: 'The query to search for in the collection.', 83 | type: new GraphQLNonNull(GraphQLString), 84 | }, 85 | minScore: { 86 | description: 'The minimum score to return.', 87 | type: GraphQLFloat, 88 | }, 89 | }, 90 | }, 91 | { 92 | isPgZomboFilter: true, 93 | }, 94 | ); 95 | 96 | return _, build; 97 | }); 98 | 99 | builder.hook( 100 | 'GraphQLObjectType:fields:field:args', 101 | (args, build, context) => { 102 | const { 103 | extend, 104 | pgSql: sql, 105 | getTypeByName, 106 | inflection, 107 | pgZomboTables, 108 | } = build; 109 | 110 | const { 111 | scope: { 112 | isPgFieldConnection, 113 | isPgFieldSimpleCollection, 114 | pgFieldIntrospection: source, 115 | }, 116 | addArgDataGenerator, 117 | field, 118 | Self, 119 | } = context; 120 | 121 | const shouldAddSearch = isPgFieldConnection || isPgFieldSimpleCollection; 122 | if (!shouldAddSearch || !pgZomboTables[source.id]) { 123 | return args; 124 | } 125 | 126 | const inputFieldName = inflection.pgZomboInputField(); 127 | const filterTypeName = inflection.pgZomboFilterType(); 128 | const FilterType = getTypeByName(filterTypeName); 129 | 130 | addArgDataGenerator((input) => ({ 131 | pgDontUseAsterisk: true, 132 | pgQuery: (queryBuilder) => { 133 | if (!input[inputFieldName]) return; 134 | 135 | const { query, minScore } = input[inputFieldName]; 136 | 137 | // TODO: translate limit/offset/orderBy into QueryDSL to improve performance. 138 | 139 | // const { limit, offset, flip } = queryBuilder.getFinalLimitAndOffset(); 140 | // const orderBy = queryBuilder.getOrderByExpressionsAndDirections(); 141 | // console.log(orderBy[0][0]); 142 | 143 | const dsl = minScore 144 | ? sql.fragment`dsl.min_score(${sql.value(minScore)}, ${sql.value( 145 | query, 146 | )})` 147 | : sql.value(query); 148 | 149 | const where = sql.fragment` 150 | ${queryBuilder.getTableAlias()} ==> ${dsl} 151 | `; 152 | queryBuilder.where(where); 153 | }, 154 | })); 155 | 156 | return extend( 157 | args, 158 | { 159 | [inputFieldName]: { 160 | description: 'A search string used to filter the collection.', 161 | type: FilterType, 162 | }, 163 | }, 164 | `Adding ZomboDB search arg to field '${field.name} of '${Self.name}'`, 165 | ); 166 | }, 167 | ); 168 | 169 | builder.hook('GraphQLObjectType:fields', (fields, build, context) => { 170 | const { 171 | graphql: { GraphQLFloat }, 172 | pg2gql, 173 | pgSql: sql, 174 | inflection, 175 | pgZomboTables, 176 | } = build; 177 | 178 | const { 179 | scope: { isPgRowType, isPgCompoundType, pgIntrospection: table }, 180 | fieldWithHooks, 181 | } = context; 182 | 183 | if ( 184 | !(isPgRowType || isPgCompoundType) || 185 | !table || 186 | table.kind !== 'class' || 187 | !pgZomboTables[table.id] 188 | ) { 189 | return fields; 190 | } 191 | 192 | const scoreFieldName = inflection.pgZomboScoreField(); 193 | 194 | return Object.assign({}, fields, { 195 | [scoreFieldName]: fieldWithHooks( 196 | scoreFieldName, 197 | ({ addDataGenerator }) => { 198 | addDataGenerator(({ alias }) => ({ 199 | pgDontUseAsterisk: true, 200 | pgQuery: (queryBuilder) => { 201 | queryBuilder.select( 202 | sql.fragment`zdb.score(${queryBuilder.getTableAlias()}.ctid)`, 203 | alias, 204 | ); 205 | }, 206 | })); 207 | return { 208 | description: 'Full-text search score.', 209 | type: GraphQLFloat, 210 | resolve: (data) => pg2gql(data[scoreFieldName], GraphQLFloat), 211 | }; 212 | }, 213 | { 214 | isPgZomboDBVRankField: true, 215 | }, 216 | ), 217 | }); 218 | }); 219 | 220 | builder.hook('GraphQLEnumType:values', (values, build, context) => { 221 | const { extend, pgSql: sql, inflection, pgZomboTables } = build; 222 | 223 | const { 224 | scope: { isPgRowSortEnum, pgIntrospection: table }, 225 | } = context; 226 | 227 | if (!isPgRowSortEnum || !table || !pgZomboTables[table.id]) { 228 | return values; 229 | } 230 | 231 | const ascFieldName = inflection.pgOrderByScoreAscEnum(); 232 | const descFieldName = inflection.pgOrderByScoreDescEnum(); 233 | const findExpr = ({ queryBuilder }) => 234 | sql.fragment`zdb.score(${queryBuilder.getTableAlias()}.ctid)`; 235 | 236 | return extend( 237 | values, 238 | { 239 | [ascFieldName]: { 240 | value: { 241 | alias: `${ascFieldName.toLowerCase()}`, 242 | specs: [[findExpr, true]], 243 | }, 244 | }, 245 | [descFieldName]: { 246 | value: { 247 | alias: `${descFieldName.toLowerCase()}`, 248 | specs: [[findExpr, false]], 249 | }, 250 | }, 251 | }, 252 | `Adding ZomboDB score columns for sorting on table '${table.name}'`, 253 | ); 254 | }); 255 | }; 256 | --------------------------------------------------------------------------------