├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── README.md ├── index.d.ts ├── index.html ├── index.js ├── package.json └── test ├── data-default.json ├── data-simple.json ├── data-websearch.json └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "es6": true 9 | }, 10 | "parser": "babel-eslint", 11 | "parserOptions": { 12 | "ecmaVersion": 2020 13 | }, 14 | "rules": { 15 | "no-console": "warn", 16 | "max-len": ["warn", 180, 4] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: API-test 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '**.md' 7 | pull_request: 8 | paths-ignore: 9 | - '**.md' 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | postgres: 17 | image: postgres:14 18 | env: 19 | POSTGRES_PASSWORD: postgres 20 | ports: 21 | - 5432:5432 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: '16.x' 33 | - run: npm i 34 | - run: npm test 35 | - run: npx nyc report --reporter=lcov > coverage.lcov && npx codecov 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .nyc_output 4 | .vscode 5 | .idea 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | test 3 | index.html 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Text-Search parser for PostgreSQL 2 | 3 | [![npm version][npm-image]][npm-url] 4 | [![build status][travis-image]][travis-url] 5 | [![coverage status][codecov-image]][codecov-url] 6 | 7 | ### Why? 8 | 9 | Using pg's `to_tsquery` directly with user input can throw errors. `plainto_tsquery` sanitizes the user input, but it's very limited (it just puts an and between words), `websearch_to_tsquery` extends this behavior a little further only between double-quotes, with followedBy operator and negations. 10 | 11 | This module allows customizable text-search operators: and, or, followedBy, not, prefix, parentheses, quoted text (same behavior than `websearch_to_tsquery`). 12 | 13 | See the [options defaults values](index.js#L52-L61) 14 | 15 | ### usage 16 | ```js 17 | const tsquery = require('pg-tsquery')(/* options can be passed to override the defaults */); 18 | 19 | pool.query('SELECT * FROM tabby WHERE to_tsvector(col) @@ to_tsquery($1)', [tsquery(str)]); 20 | 21 | // or get a reusable instance 22 | const {Tsquery} = require('pg-tsquery'); 23 | 24 | const parser = new Tsquery(/* options can be passed to override the defaults */); 25 | 26 | // then process your input with parser.parse(str).toString() 27 | ``` 28 | 29 | 30 | | inputs | output | 31 | | --- | --- | 32 | | `foo bar` | `foo&bar` | 33 | | `foo -bar`, `foo !bar`, `foo + !bar` | `foo&!bar` | 34 | | `foo bar,bip`, `foo+bar \| bip` | `foo&bar\|bip` | 35 | | `foo (bar,bip)`, `foo+(bar\|bip)` | `foo&(bar\|bip)` | 36 | | `foo>bar>bip` | `foo<->bar<->bip` | 37 | | `foo*,bar* bana:*` | `foo:*\|bar:*&bana:*` | 38 | 39 | 40 | ### [Demo](https://caub.github.io/pg-tsquery) 41 | 42 | [npm-image]: https://img.shields.io/npm/v/pg-tsquery.svg?style=flat-square 43 | [npm-url]: https://www.npmjs.com/package/pg-tsquery 44 | [travis-image]: https://img.shields.io/travis/caub/pg-tsquery.svg?style=flat-square 45 | [travis-url]: https://travis-ci.org/caub/pg-tsquery 46 | [codecov-image]: https://img.shields.io/codecov/c/github/caub/pg-tsquery.svg?style=flat-square 47 | [codecov-url]: https://codecov.io/gh/caub/pg-tsquery 48 | 49 | ### Support 50 | 51 | Please consider reporting issues and trying to create a pull request as well 52 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace tsquery { 2 | export interface TsqueryOptions { 3 | word?: RegExp; // regex for a word. It must contain named captures: word (required), quote (optional), phrase (optional), negated (optional) 4 | negated?: RegExp; // regex to detect if the expression following an operator is negated, this is useful for example with 'foo!bar', you can parse it as foo&!bar by adding the negation as part of and 5 | quotedWordSep?: RegExp; // regex for word delimiters inside quotes 6 | or?: RegExp; // regex for `or` operator 7 | and?: RegExp; // regex for `and` operator 8 | followedBy?: RegExp; // regex for `followedBy` operator 9 | parStart?: RegExp; // regex for start of parenthesized group 10 | parEnd?: RegExp; // regex for end of parenthesized group 11 | prefix?: RegExp; // regex for detecting `prefix` operator (placed at the end of a word to match words starting like it) 12 | tailOp?: string; // default operator to use with tail (unparsed suffix of the query, if any) 13 | singleQuoteReplacement?: string; // should not be a reserved word like <()|&!]|:\* 14 | } 15 | 16 | interface BaseNode { 17 | readonly type: T; 18 | readonly negated: boolean | undefined; 19 | readonly left: TsqueryNode | undefined; 20 | readonly right: TsqueryNode | undefined; 21 | readonly value: string | undefined; 22 | readonly prefix: string | undefined; 23 | readonly quoted: boolean | undefined; 24 | toString(): string; 25 | } 26 | 27 | export interface AndNode extends BaseNode<"&"> { 28 | readonly left: TsqueryNode; 29 | readonly right: TsqueryNode; 30 | readonly value: undefined; 31 | readonly prefix: undefined; 32 | readonly quoted: undefined; 33 | } 34 | 35 | export interface OrNode extends BaseNode<"|"> { 36 | readonly left: TsqueryNode; 37 | readonly right: TsqueryNode; 38 | readonly value: undefined; 39 | readonly prefix: undefined; 40 | readonly quoted: undefined; 41 | } 42 | 43 | export interface FollowedByNode extends BaseNode<`<${"-" | number}>`> { 44 | readonly left: WordNode; 45 | readonly right: WordNode; 46 | readonly value: undefined; 47 | readonly prefix: undefined; 48 | readonly quoted: undefined; 49 | } 50 | 51 | export interface WordNode extends BaseNode { 52 | readonly value: string; 53 | readonly left: undefined; 54 | readonly right: undefined; 55 | readonly quoted: boolean; 56 | } 57 | 58 | export type TsqueryNode = AndNode | OrNode | FollowedByNode | WordNode; 59 | 60 | export class Tsquery { 61 | constructor(options?: TsqueryOptions); 62 | parseAndStringify(str: string): string; 63 | parse(str: string): TsqueryNode | undefined; 64 | } 65 | } 66 | 67 | declare function tsquery(options?: tsquery.TsqueryOptions): (str: string) => string; 68 | 69 | interface tsquery { 70 | (options?: tsquery.TsqueryOptions): (str: string) => string; 71 | Tsquery: tsquery.Tsquery; 72 | Node: tsquery.TsqueryNode; 73 | } 74 | 75 | export = tsquery; 76 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pg-tsquery 5 | 43 | 44 | 45 |
46 |

pg-tsquery

47 |
48 |
49 | 50 | ... 51 |
52 | 53 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import("./index").TsqueryOptions } TsqueryOptions 3 | */ 4 | 5 | const PRECEDENCES = { 6 | '|': 0, 7 | '&': 1, 8 | '<': 2, 9 | }; 10 | 11 | class Node { 12 | constructor({ type, input, negated, left, right, value, prefix, quoted }) { 13 | this.type = type; // '&'|'|'|'<->'|'<1>'|'<2>'.. or undefined if a word node (leaf node) 14 | this.input = input; // remaining string to parse 15 | this.negated = negated; // boolean 16 | this.left = left; // Node 17 | this.right = right; // Node 18 | this.value = value; // word node string value 19 | this.prefix = prefix; // word node prefix when using a :* operator 20 | this.quoted = quoted; // whether the node is wrapped in quotes (aka websearch_to_tsquery) 21 | } 22 | 23 | toString() { 24 | if (!this.type) { 25 | if (!this.value) return ''; // avoid just ! (negated empty word, shouldn't happen with proper non-empty word regex tho) 26 | 27 | const prefixed = `${this.value}${this.prefix ? ':*' : ''}`; 28 | return this.negated && this.quoted 29 | ? `!(${prefixed})` 30 | : `${this.negated ? '!' : ''}${prefixed}`; 31 | } 32 | 33 | let left = this.left; 34 | let right = this.right; 35 | 36 | if (left.type && PRECEDENCES[this.type[0]] > PRECEDENCES[left.type[0]] && !left.negated) { 37 | // wrap left in parens 38 | left = `(${left})`; 39 | } 40 | if (right.type && PRECEDENCES[this.type[0]] > PRECEDENCES[right.type[0]] && !right.negated) { 41 | // wrap right in parens 42 | right = `(${right})`; 43 | } 44 | const content = `${left}${this.type}${right}`; 45 | return this.negated ? `!(${content})` : content; 46 | } 47 | } 48 | 49 | 50 | class Tsquery { 51 | /** 52 | * 53 | * @param {TsqueryOptions} opts 54 | */ 55 | constructor({ 56 | or = /^\s*(?:[|,]|or)/i, 57 | and = /^(?!\s*(?:[|,]|or))(?:[\s&+:|,!-]|and)*/i, // /^\s*(?:[\s&+:|,!-]|and)*/i, 58 | followedBy = /^\s*>/, // /^\s*<(?:(?:(\d+)|-)?>)?/, 59 | word = /^[\s*&+<:,|]*(?[\s!-]*)[\s*&+<:,|]*(?:(?["'])(?.*?)\k|(?[^\s,|&+<>:*()[\]!]+))/, 60 | quotedWordSep = /(?:[\s<()|&!]|:\*)+/, // those are mostly tsquery operator, not removing them would cause errors 61 | parStart = /^\s*[!-]*[([]/, 62 | parEnd = /^[)\]]/, 63 | negated = /[!-]$/, 64 | prefix = /^(\*|:\*)*/, 65 | tailOp = '&', 66 | singleQuoteReplacement = '', 67 | 68 | } = {}) { 69 | this.or = or; 70 | this.and = and; 71 | this.followedBy = followedBy; 72 | this.word = word; 73 | this.quotedWordSep = quotedWordSep; 74 | this.parStart = parStart; 75 | this.parEnd = parEnd; 76 | this.negated = negated; 77 | this.prefix = prefix; 78 | this.tailOp = tailOp; 79 | this.singleQuoteReplacement = singleQuoteReplacement; 80 | } 81 | 82 | parseAndStringify(str) { 83 | return `${this.parse(str) || ''}`; 84 | } 85 | 86 | /** 87 | * Parse string as a Node tree, invoke .toString() to get back a string value for thta tree 88 | * @param {string} str 89 | * @returns {Node|undefined} 90 | */ 91 | parse(str) { 92 | let node = this.parseOr(str); 93 | let tail = node && node.input; 94 | while (tail && this.tailOp) { 95 | tail = tail.slice(1); 96 | const right = this.parseOr(tail); 97 | if (right) { 98 | node = new Node({ 99 | type: this.tailOp, 100 | input: right.input, 101 | negated: false, 102 | left: node, 103 | right, 104 | value: undefined, 105 | prefix: undefined, 106 | quoted: undefined, 107 | }); 108 | tail = node.input; 109 | } 110 | } 111 | return node; 112 | } 113 | 114 | /** 115 | * 116 | * @returns {Node|undefined} 117 | */ 118 | parseOr(str) { 119 | let node = this.parseAnd(str); 120 | 121 | while (node && node.input && this.or) { 122 | const m = node.input.match(this.or); 123 | if (!m) return node; 124 | const s = node.input.slice(m[0].length); 125 | const right = this.parseAnd(s); 126 | if (!right) return node; 127 | right.negated = right.negated || this.negated.test(m[0]); 128 | node = new Node({ 129 | type: '|', 130 | input: right.input, 131 | negated: false, 132 | left: node, 133 | right, 134 | value: undefined, 135 | prefix: undefined, 136 | quoted: undefined, 137 | }); 138 | } 139 | return node; 140 | } 141 | 142 | /** 143 | * 144 | * @returns {Node|undefined} 145 | */ 146 | parseAnd(str) { 147 | let node = this.parseFollowedBy(str); 148 | 149 | while (node && node.input && this.and) { 150 | const m = node.input.match(this.and); 151 | if (!m) return node; 152 | const s = node.input.slice(m[0].length); 153 | const right = this.parseFollowedBy(s); 154 | if (!right) return node; 155 | right.negated = right.negated || this.negated.test(m[0]); 156 | node = new Node({ 157 | type: '&', 158 | input: right.input, 159 | negated: false, 160 | left: node, 161 | right, 162 | value: undefined, 163 | prefix: undefined, 164 | quoted: undefined, 165 | }); 166 | } 167 | return node; 168 | } 169 | 170 | /** 171 | * 172 | * @returns {Node|undefined} 173 | */ 174 | parseFollowedBy(str) { 175 | let node = this.parseWord(str); 176 | 177 | while (node && node.input && this.followedBy) { 178 | const m = node.input.match(this.followedBy); 179 | if (!m) return node; 180 | const s = node.input.slice(m[0].length); 181 | const right = this.parseWord(s); 182 | if (!right) return node; 183 | right.negated = right.negated || this.negated.test(m[0]); 184 | node = new Node({ 185 | type: m[1] ? `<${m[1]}>` : '<->', 186 | input: right.input, 187 | negated: false, 188 | left: node, 189 | right, 190 | value: undefined, 191 | prefix: undefined, 192 | quoted: undefined, 193 | }); 194 | } 195 | return node; 196 | } 197 | 198 | /** 199 | * 200 | * @returns {Node|undefined} 201 | */ 202 | parseWord(str) { 203 | const s = str.trimStart(); 204 | const par = s.match(this.parStart); 205 | if (par) { 206 | const s2 = s.slice(par[0].length); 207 | const node = this.parseOr(s2); 208 | if (node) { 209 | node.negated = node.negated || par[0].length > 1; 210 | node.input = node.input.trimStart().replace(this.parEnd, ''); 211 | } 212 | return node; 213 | } 214 | const m = s.match(this.word); 215 | 216 | if (m === null || !m.groups) { 217 | return; 218 | } 219 | const next = s.slice(m[0].length); 220 | const prefix = this.prefix ? next.match(this.prefix)[0] : ''; 221 | const input = next.slice(prefix.length); 222 | const value = m.groups.word 223 | ? m.groups.word.replace(/'/g, this.singleQuoteReplacement) // replace single quotes, else you'd get a syntax error in pg's ts_query 224 | : `"${m.groups.phrase.split(this.quotedWordSep).join('<->')}"`; // it looks nasty, but to_tsquery will handle this well, see tests, in the end it behaves like websearch_to_tsquery 225 | const negated = !!m.groups.negated; 226 | 227 | return new Node({ 228 | type: undefined, 229 | value, 230 | negated, 231 | left: undefined, 232 | right: undefined, 233 | input, 234 | prefix, 235 | quoted: !!m.groups.quote, 236 | }); 237 | } 238 | } 239 | 240 | /** 241 | * Initializes tsquery parser 242 | * @param {TsqueryOptions} opts 243 | * @returns {(string) => string} 244 | */ 245 | function tsquery(opts) { 246 | const parser = new Tsquery(opts); 247 | return str => `${parser.parse(str) || ''}`; 248 | } 249 | 250 | tsquery.Tsquery = Tsquery; 251 | tsquery.Node = Node; 252 | 253 | module.exports = tsquery; 254 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pg-tsquery", 3 | "version": "8.4.2", 4 | "description": "Parse user input into valid text search queries", 5 | "engines": { 6 | "node": ">=10" 7 | }, 8 | "scripts": { 9 | "test": "eslint . && tsc --target es2020 --noEmit --alwaysStrict --allowJs --checkJs index.js && nyc node test" 10 | }, 11 | "repository": "github:caub/pg-tsquery", 12 | "keywords": [ 13 | "pg", 14 | "postgres", 15 | "PostgreSQL", 16 | "tsquery", 17 | "text", 18 | "search", 19 | "parser" 20 | ], 21 | "author": "caub", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/caub/pg-tsquery/issues" 25 | }, 26 | "homepage": "https://github.com/caub/pg-tsquery#readme", 27 | "devDependencies": { 28 | "@types/node": "^14.14.34", 29 | "babel-eslint": "^10.1.0", 30 | "codecov": "^3.8.1", 31 | "eslint": "^7.22.0", 32 | "nyc": "^15.1.0", 33 | "pg": "^8.5.1", 34 | "typescript": "^4.2.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/data-default.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "foo ", 4 | "foo" 5 | ], 6 | [ 7 | "blue sea,sun", 8 | "blue&sea|sun" 9 | ], 10 | [ 11 | " hmm I like tomatoes ", 12 | "hmm&I&like&tomatoes" 13 | ], 14 | [ 15 | " o(x)o ", 16 | "o&x&o" 17 | ], 18 | [ 19 | " o(x)-o ", 20 | "o&x&!o" 21 | ], 22 | [ 23 | " !o(x)o ", 24 | "!o&x&o" 25 | ], 26 | [ 27 | " o(-x)o ", 28 | "o&!x&o" 29 | ], 30 | [ 31 | "-(foo (bar,sib)) ok", 32 | "!(foo&(bar|sib))&ok" 33 | ], 34 | [ 35 | "-(fast|fox) ok", 36 | "!(fast|fox)&ok" 37 | ], 38 | [ 39 | "(fast|fox) q", 40 | "(fast|fox)&q" 41 | ], 42 | [ 43 | "(fa(st ,, , fox) quic", 44 | "fa&(st|fox)&quic" 45 | ], 46 | [ 47 | "!he!llo", 48 | "!he&!llo" 49 | ], 50 | [ 51 | " o | \nhb &,pl", 52 | "o|hb&pl" 53 | ], 54 | [ 55 | " rgr |, , ok,,ok+rg*rh*t&jnj&j&&jn\n\nrgr", 56 | "rgr|ok|ok&rg:*&rh:*&t&jnj&j&jn&rgr" 57 | ], 58 | [ 59 | " ,,,,,, ", 60 | "" 61 | ], 62 | [ 63 | " ,,,,+&&++ ", 64 | "" 65 | ], 66 | [ 67 | " ,,,,+|+\"+ &!& ", 68 | "\"" 69 | ], 70 | [ 71 | ":*,,, lol", 72 | "lol" 73 | ], 74 | [ 75 | " foo,bar ,` lol !,ok, !! -blue ", 76 | "foo|bar|`&lol&ok|!blue" 77 | ], 78 | [ 79 | " foo+---bar , lol `+ok+ blue ", 80 | "foo&!bar|lol&`&ok&blue" 81 | ], 82 | [ 83 | " foo& &&!bar - & | '\"\"' & lol +,ok+ blue \"\" ", 84 | "foo&!bar&\"\"\"\"&lol&ok&blue&\"\"" 85 | ], 86 | [ 87 | " (h(e((ll))o, (nas(ty)), (world\t\t", 88 | "h&(e&ll&o|nas&ty|world)" 89 | ], 90 | [ 91 | " (h(e((ll))o, (nas(ty)) world\t\t", 92 | "h&(e&ll&o|nas&ty&world)" 93 | ], 94 | [ 95 | " (h(e((ll))o (nas(ty)), )world\t\t", 96 | "h&e&ll&o&nas&ty&world" 97 | ], 98 | [ 99 | "follo* | by , nothin* <2> \n ", 100 | "follo:*|by|nothin:*&2" 101 | ], 102 | [ 103 | " (lol (foo(bar (ok, ok", 104 | "lol&foo&bar&(ok|ok)" 105 | ], 106 | [ 107 | "Test \"quoted text G1-ABC +37544:*\" ", 108 | "Test&\"quoted<->text<->G1-ABC<->+37544<->\"" 109 | ], 110 | [ 111 | " Please, contribute > to this proj* and repo* by add* mor* (ex*,example*)! Thanks", 112 | "Please|contribute<->to&this&proj:*&repo:*&by&add:*&mor:*&(ex:*|example:*)&Thanks" 113 | ], 114 | [ 115 | "'abs", 116 | "abs" 117 | ], 118 | [ 119 | "TS01-H-18011-005", 120 | "TS01-H-18011-005" 121 | ], 122 | [ 123 | "TS01 -H -18011 -005", 124 | "TS01&!H&!18011&!005" 125 | ], 126 | [ 127 | "-\"keyword\"*", 128 | "!(\"keyword\":*)" 129 | ] 130 | ] 131 | -------------------------------------------------------------------------------- /test/data-simple.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "foo ", 4 | "foo" 5 | ], 6 | [ 7 | "blue sea,sun", 8 | "blue&sea|sun" 9 | ], 10 | [ 11 | " hmm I like tomatoes ", 12 | "hmm&I&like&tomatoes" 13 | ], 14 | [ 15 | " o(x)o ", 16 | "o&x&o" 17 | ], 18 | [ 19 | " o(x)-o ", 20 | "o&x&-o" 21 | ], 22 | [ 23 | " !o(x)o ", 24 | "!o&x&o" 25 | ], 26 | [ 27 | " o(-x)o ", 28 | "o&-x&o" 29 | ], 30 | [ 31 | "-(foo (bar,sib)) ok", 32 | "-&foo&(bar|sib)&ok" 33 | ], 34 | [ 35 | "-(fast|fox) ok", 36 | "-&(fast|fox)&ok" 37 | ], 38 | [ 39 | "(fast|fox) q", 40 | "(fast|fox)&q" 41 | ], 42 | [ 43 | "(fa(st ,, , fox) quic", 44 | "fa&(st|fox)&quic" 45 | ], 46 | [ 47 | "!he!llo", 48 | "!he&!llo" 49 | ], 50 | [ 51 | " o | \nhb &,pl", 52 | "o|hb&pl" 53 | ], 54 | [ 55 | " rgr |, , ok,,ok+rg*rh*t&jnj&j&&jn\n\nrgr", 56 | "rgr|ok|ok+rg:*&rh:*&t&jnj&j&jn&rgr" 57 | ], 58 | [ 59 | " ,,,,,, ", 60 | "" 61 | ], 62 | [ 63 | " ,,,,+&&++ ", 64 | "+&++" 65 | ], 66 | [ 67 | " ,,,,+|+\"+ &!& ", 68 | "+|+\"+" 69 | ], 70 | [ 71 | ":*,,, lol", 72 | "lol" 73 | ], 74 | [ 75 | " foo,bar ,` lol !,ok, !! -blue ", 76 | "foo|bar|`&lol&ok|!-blue" 77 | ], 78 | [ 79 | " foo+---bar , lol `+ok+ blue ", 80 | "foo+---bar|lol&`+ok+&blue" 81 | ], 82 | [ 83 | " foo& &&!bar - & | '\"\"' & lol +,ok+ blue \"\" ", 84 | "foo&!bar&-&\"\"&lol&+|ok+&blue&\"\"" 85 | ], 86 | [ 87 | " (h(e((ll))o, (nas(ty)), (world\t\t", 88 | "h&(e&ll&o|nas&ty|world)" 89 | ], 90 | [ 91 | " (h(e((ll))o, (nas(ty)) world\t\t", 92 | "h&(e&ll&o|nas&ty&world)" 93 | ], 94 | [ 95 | " (h(e((ll))o (nas(ty)), )world\t\t", 96 | "h&e&ll&o&nas&ty&world" 97 | ], 98 | [ 99 | "follo* | by , nothin* <2> \n ", 100 | "follo:*|by|nothin:*&2>" 101 | ], 102 | [ 103 | " (lol (foo(bar (ok, ok", 104 | "lol&foo&bar&(ok|ok)" 105 | ], 106 | [ 107 | "Test \"quoted text G1-ABC +37544:*\" ", 108 | "Test&\"quoted&text&G1-ABC&+37544:*&\"" 109 | ], 110 | [ 111 | " Please, contribute > to this proj* and repo* by add* mor* (ex*,example*)! Thanks", 112 | "Please|contribute<->to&this&proj:*&and&repo:*&by&add:*&mor:*&(ex:*|example:*)&Thanks" 113 | ], 114 | [ 115 | "'abs", 116 | "abs" 117 | ] 118 | ] 119 | -------------------------------------------------------------------------------- /test/data-websearch.json: -------------------------------------------------------------------------------- 1 | [ 2 | [ 3 | "figs -\"yellow bananas\" or persimmons", 4 | "figs&!(\"yellow<->bananas\")|persimmons", 5 | "'fig' & !( 'yellow' <-> 'banana' ) | 'persimmon'" 6 | ] 7 | ] 8 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { Tsquery } = require('../index'); 3 | const pg = require('pg'); 4 | const data = require('./data-default.json'); 5 | const dataSimple = require('./data-simple.json'); 6 | const dataWebsearch = require('./data-websearch.json'); 7 | 8 | const pool = new pg.Pool({ connectionString: 'pg://postgres:postgres@localhost:5432/postgres' }); 9 | 10 | const tsquery = new Tsquery(); // use default config 11 | const tsquerySimple = new Tsquery({ 12 | or: /^\s*[|,]/, 13 | and: /^(?!\s*[|,])[\s&:|,!]*/, 14 | followedBy: /^\s*>/, 15 | word: /^[\s*&<:,|]*(?[\s!]*)[\s*&<:,|]*(?[^\s,|&<:*()!]+)/, 16 | parStart: /^\s*!*[(]/, 17 | parEnd: /^[)]/, 18 | negated: /!$/, 19 | prefix: /^(\*|:\*)*/, 20 | tailOp: '&', 21 | }); 22 | 23 | async function test(tsquery, data) { 24 | for (const [q, expected] of data) { 25 | assert.strictEqual(`${tsquery.parse(q) || ''}`, expected, `for: ${q}`); 26 | } 27 | // test against pg's to_tsquery, it should not throw thanks to this module 28 | await pool.query(`select to_tsquery($1)`, ['this crashes']).catch(e => assert(e)); 29 | 30 | await pool 31 | .query(`select to_tsvector('a quick brown fox') @@ plainto_tsquery($1) as x`, ['quick,fast fox']) 32 | .then(({ rows: [{ x }] }) => assert(!x)); 33 | 34 | await pool 35 | .query(`select to_tsvector('a quick brown fox') @@ to_tsquery($1) as x`, [ 36 | tsquery.parse('fast ,, , fox quic') + ':*', 37 | ]) 38 | .then(({ rows: [{ x }] }) => { 39 | assert(x); 40 | }); 41 | 42 | for (const [s, , expectedQuery] of data) { 43 | const tq = `${tsquery.parse(s) || ''}`; 44 | const { rows } = await pool.query(`select to_tsquery($1)`, [tq]); 45 | if (expectedQuery) assert.strictEqual(rows[0].to_tsquery, expectedQuery); 46 | } 47 | 48 | // quick perf test 49 | const tests = [].concat(...Array.from({ length: 1e3 }, (_, i) => data.map(a => `${a[0]} ${i || ''}`))); 50 | 51 | console.time('- perf basic'); 52 | for (const t of tests) { 53 | t.match(/[^\s()<&!|:]+/g).join('&'); 54 | } 55 | console.timeEnd('- perf basic'); 56 | 57 | console.time('- perf tsquery'); 58 | for (const t of tests) { 59 | `${tsquery.parse(t)}`; 60 | } 61 | console.timeEnd('- perf tsquery'); 62 | } 63 | 64 | (async () => { 65 | try { 66 | await test(tsquery, dataWebsearch); 67 | console.log('websearch dataset OK'); 68 | 69 | await test(tsquery, data); 70 | console.log('default dataset OK'); 71 | 72 | await test(tsquerySimple, dataSimple); 73 | console.log('simple dataset OK'); 74 | } catch (err) { 75 | console.error(err); 76 | process.exitCode = 1; 77 | } finally { 78 | await pool.end(); 79 | } 80 | })(); 81 | --------------------------------------------------------------------------------