├── .coveralls.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── mysql-railroad.svg └── pg-railroad.svg ├── index.js ├── lib ├── escapers.js ├── fragment.js ├── id.js ├── mysql-escaper.js ├── mysql-lexer.js ├── pg-escaper.js ├── pg-lexer.js └── tag-fn.js ├── package-lock.json ├── package.json ├── scripts ├── make-md-toc.pl ├── prepublish.sh └── validate.sh └── test ├── escapers-test.js ├── example-test.js ├── mysql-lexer-test.js ├── mysql-tag-test.js ├── pg-lexer-test.js └── pg-tag-test.js /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | repo_token: 2aCRkrfmsLJFPpAzjwIu5RDRBY6JQSszA 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Emacs droppings 9 | *~ 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | - "8" 5 | - "9" 6 | - "10" 7 | - "stable" 8 | 9 | # Use faster Docker architecture on Travis. 10 | sudo: false 11 | 12 | script: ./scripts/validate.sh 13 | after_success: npm run coveralls 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Sisyphus Logo 2 | 3 | # Safe SQL Template Tag 4 | 5 | [![Build Status](https://travis-ci.org/mikesamuel/safesql.svg?branch=master)](https://travis-ci.org/mikesamuel/safesql) 6 | [![Dependencies Status](https://david-dm.org/mikesamuel/safesql/status.svg)](https://david-dm.org/mikesamuel/safesql) 7 | [![npm](https://img.shields.io/npm/v/safesql.svg)](https://www.npmjs.com/package/safesql) 8 | [![Coverage Status](https://coveralls.io/repos/github/mikesamuel/safesql/badge.svg?branch=master)](https://coveralls.io/github/mikesamuel/safesql?branch=master) 9 | [![Install Size](https://packagephobia.now.sh/badge?p=safesql)](https://packagephobia.now.sh/result?p=safesql) 10 | [![Known Vulnerabilities](https://snyk.io/test/github/mikesamuel/safesql/badge.svg?targetFile=package.json)](https://snyk.io/test/github/mikesamuel/safesql?targetFile=package.json) 11 | 12 | Provides a string template tag that makes it easy to compose 13 | [MySQL][mysql] and [PostgreSQL][pg] query strings from untrusted 14 | inputs by escaping dynamic values based on the context in which they 15 | appear. 16 | 17 | 18 | 19 | 20 | 21 | * [Installation](#installation) 22 | * [Supported Databases](#supported) 23 | * [Usage By Example](#usage) 24 | * [`mysql` returns a *SqlFragment*](#sql-returns-sqlfragment) 25 | * [No excess quotes](#minimal-quotes) 26 | * [Escaped backticks delimit SQL identifiers](#escaped-backticks) 27 | * [Escape Sequences are Raw](#raw-escapes) 28 | * [API](#API) 29 | * [mysql(options)](#mysql-options) 30 | * [pgsql(options)](#pg-options) 31 | * [mysql\`...\`](#mysql-as-tag) 32 | * [pg\`...\`](#pg-as-tag) 33 | * [SqlFragment](#class-SqlFragment) 34 | * [SqlId](#class-SqlId) 35 | 36 | 37 | 38 | ## Installation 39 | 40 | ```bash 41 | $ npm install safesql 42 | ``` 43 | 44 | ## Supported Databases 45 | 46 | **MySQL** via 47 | 48 | ```js 49 | const { mysql } = require('safesql'); 50 | ``` 51 | 52 | **PostgreSQL** via 53 | 54 | ```js 55 | const { pg } = require('safesql'); 56 | ``` 57 | 58 | 59 | ## Usage By Example 60 | 61 | 67 | 68 | ```js 69 | const { mysql, SqlId } = require('safesql'); 70 | 71 | const table = 'table'; 72 | const ids = [ 'x', 'y', 'z' ]; 73 | const str = 'foo\'"bar'; 74 | 75 | const query = mysql`SELECT * FROM \`${ table }\` WHERE id IN (${ ids }) AND s=${ str }`; 76 | 77 | console.log(query); 78 | // SELECT * FROM `table` WHERE id IN ('x', 'y', 'z') AND s='foo''"bar' 79 | ``` 80 | 81 | `mysql` functions as a template tag. 82 | 83 | Commas separate elements of arrays in the output. 84 | 85 | `mysql` treats a `${...}` between backticks (\\\`) as a SQL identifier. 86 | 87 | A `${...}` outside any quotes will be escaped and wrapped in appropriate quotes if necessary. 88 | 89 | ---- 90 | 91 | PostgreSQL differs from MySQL in important ways. Use `pg` for Postgres. 92 | 93 | ```js 94 | const { pg, SqlId } = require('safesql'); 95 | 96 | const table = 'table'; 97 | const ids = [ 'x', 'y', 'z' ]; 98 | const str = 'foo\'"bar'; 99 | 100 | const query = pg`SELECT * FROM "${ table }" WHERE id IN (${ ids }) AND s=${ str }`; 101 | 102 | console.log(query); 103 | // SELECT * FROM "table" WHERE id IN ('x', 'y', 'z') AND s=e'foo''\"bar' 104 | ``` 105 | 106 | ---- 107 | 108 | You can pass in an object to relate columns to values as in a `SET` clause above. 109 | 110 | The output of mysql\`...\` has type *SqlFragment* so the 111 | `NOW()` function call is not re-escaped when used in `${data}`. 112 | 113 | ```js 114 | const { mysql } = require('safesql'); 115 | 116 | const column = 'users'; 117 | const userId = 1; 118 | const data = { 119 | email: 'foobar@example.com', 120 | modified: mysql`NOW()` 121 | }; 122 | const query = mysql`UPDATE \`${column}\` SET ${data} WHERE \`id\` = ${userId}`; 123 | 124 | console.log(query); 125 | // UPDATE `users` SET `email` = 'foobar@example.com', `modified` = NOW() WHERE `id` = 1 126 | ``` 127 | 128 | ### `mysql` returns a *SqlFragment* 129 | 130 | Since `mysql` returns a *SqlFragment* you can chain uses: 131 | 132 | ```js 133 | const { mysql } = require('safesql'); 134 | 135 | const data = { a: 1 }; 136 | const whereClause = mysql`WHERE ${data}`; 137 | console.log(mysql`SELECT * FROM TABLE ${whereClause}`); 138 | // SELECT * FROM TABLE WHERE `a` = 1 139 | ``` 140 | 141 | ### No excess quotes 142 | 143 | An interpolation in a quoted string will not insert excess quotes: 144 | 145 | ```js 146 | const { mysql } = require('safesql') 147 | 148 | console.log(mysql`SELECT '${ 'foo' }' `) 149 | // SELECT 'foo' 150 | console.log(mysql`SELECT ${ 'foo' } `) 151 | // SELECT 'foo' 152 | ``` 153 | 154 | ### Escaped backticks delimit SQL identifiers 155 | 156 | Backticks end a template tag, so you need to escape backticks. 157 | 158 | ```js 159 | const { mysql } = require('safesql') 160 | 161 | console.log(mysql`SELECT \`${ 'id' }\` FROM \`TABLE\``) 162 | // SELECT `id` FROM `TABLE` 163 | ``` 164 | 165 | ### Escape Sequences are Raw 166 | 167 | Other escape sequences are raw. 168 | 169 | ```js 170 | const { mysql } = require('safesql') 171 | 172 | console.log(mysql`SELECT "\n"`) 173 | // SELECT "\n" 174 | ``` 175 | 176 | ## API 177 | 178 | Assuming 179 | 180 | ```js 181 | const { mysql, pg, SqlFragment, SqlId } = require('safesql') 182 | ``` 183 | 184 | ### mysql(options) 185 | ### pgsql(options) 186 | 187 | When called with an options bundle instead of as a template tag, 188 | `mysql` and `pg` return a template tag that uses those options. 189 | 190 | The options object can contain any of 191 | `{ stringifyObjects, timeZone, forbidQualified }` which have the 192 | same meaning as when used with *[sqlstring][]*. 193 | 194 | ```js 195 | const timeZone = 'GMT' 196 | const date = new Date(Date.UTC(2000, 0, 1)) 197 | 198 | console.log(mysql({ timeZone })`SELECT ${date}`) 199 | // SELECT '2000-01-01 00:00:00.000' 200 | ``` 201 | 202 | ### mysql\`...\` 203 | 204 | When used as a template tag, chooses an appropriate escaping 205 | convention for each `${...}` based on the context in which it appears. 206 | 207 | `mysql` handles `${...}` inside quoted strings as if the template 208 | matched the following grammar: 209 | 210 | [![Railroad Diagram][mysql-railroad-raw]][mysql-railroad] 211 | 212 | ### pg\`...\` 213 | 214 | When used as a template tag, chooses an appropriate escaping 215 | convention for each `${...}` based on the context in which it appears. 216 | 217 | `pg` handles `${...}` inside quoted strings as if the template 218 | matched the following grammar: 219 | 220 | [![Railroad Diagram][pg-railroad-raw]][pg-railroad] 221 | 222 | ### SqlFragment 223 | 224 | *SqlFragment* is a [Mintable][] class that represents fragments of SQL 225 | that are safe to send to a database. 226 | 227 | See [minting][] for example on how to create instances, and why this is a 228 | tad more involved than just using `new`. 229 | 230 | ### SqlId 231 | 232 | *SqlId* is a [Mintable][] class that represents a SQL identifier. 233 | 234 | See [minting][] for example on how to create instances, and why this is a 235 | tad more involved than just using `new`. 236 | 237 | A `SqlId`'s content must be the raw text of a SQL identifier and 238 | creators should not rely on case folding by the database client. 239 | 240 | 241 | [mysql]: https://www.npmjs.com/package/mysql 242 | [pg]: https://www.npmjs.com/package/pg 243 | [sqlstring]: https://www.npmjs.com/package/sqlstring 244 | [Mintable]: https://www.npmjs.com/package/node-sec-patterns 245 | [minting]: https://www.npmjs.com/package/node-sec-patterns#creating-mintable-values 246 | 247 | [mysql-railroad]: docs/mysql-railroad.svg 248 | [mysql-railroad-raw]: https://rawgit.com/mikesamuel/safesql/master/docs/mysql-railroad.svg 249 | [pg-railroad]: docs/pg-railroad.svg 250 | [pg-railroad-raw]: https://rawgit.com/mikesamuel/safesql/master/docs/pg-railroad.svg 251 | -------------------------------------------------------------------------------- /docs/mysql-railroad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 90 | 91 | 92 | 94 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | block comment 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | /* 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | / 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | * 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | [^*/] 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | */ 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | line comment 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | # 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | -- 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | space 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | [^\r\n] 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | hole 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | ${ 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | escape 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | } 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | normal char 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | [^'"`\\] 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | escape 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | \ 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | char 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | quoted 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | " 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | "" 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | [^"\\] 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | \ 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | char 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | ${ 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | escapeChars 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | } 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | " 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | quoted 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | ' 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | '' 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | [^'\\] 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | \ 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | char 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | ${ 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | escapeChars 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | } 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | ' 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | id 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | \ 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | ` 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | \ 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | ` 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | \ 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | ` 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | [^`\\] 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | \ 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | char 776 | 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | ${ 788 | 789 | 790 | 791 | 792 | 793 | 794 | 795 | escapeIdChars 796 | 797 | 798 | 799 | 800 | 801 | 802 | 803 | } 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | 829 | 830 | \ 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | ` 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | 856 | -------------------------------------------------------------------------------- /docs/pg-railroad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 110 | 111 | 112 | 114 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | block comment 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | /* 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | nested block comment 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | * 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | / 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | [^*/] 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | */ 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | line comment 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | -- 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | space 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | [^\r\n] 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | hole 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | ${ 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | escape 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | } 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | normal char 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | [^'"$\\] 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | escape 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | \ 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | char 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | id 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | U& 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | " 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | "" 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | [^"\\] 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | \ 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | char 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | ${ 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | escapeIdChars 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | } 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | " 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | quoted string 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | B 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | X 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | ' 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | '' 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | [^'] 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | ${ 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | escapeChars 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | } 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | ' 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | continuation 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | escaped 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | U& 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | E 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | ' 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | '' 774 | 775 | 776 | 777 | 778 | 779 | 780 | 781 | [^'\\] 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | 791 | 792 | \ 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | char 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | 812 | ${ 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | escapeChars 821 | 822 | 823 | 824 | 825 | 826 | 827 | 828 | } 829 | 830 | 831 | 832 | 833 | 834 | 835 | 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | ' 848 | 849 | 850 | 851 | 852 | 853 | 854 | 855 | continuation 856 | 857 | 858 | 859 | 860 | 861 | 862 | 863 | 864 | 865 | 866 | literal 867 | 868 | 869 | 870 | 871 | 872 | 873 | 874 | $ 875 | 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 885 | 886 | 887 | 888 | 889 | 890 | tag 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | $ 900 | 901 | 902 | 903 | 904 | 905 | 906 | 907 | 908 | 909 | 910 | 911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | char 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | 933 | 934 | ${ 935 | 936 | 937 | 938 | 939 | 940 | 941 | 942 | literalChars 943 | 944 | 945 | 946 | 947 | 948 | 949 | 950 | } 951 | 952 | 953 | 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 963 | 964 | 965 | 966 | 967 | 968 | 969 | $ 970 | 971 | 972 | 973 | 974 | 975 | 976 | 977 | same tag 978 | 979 | 980 | 981 | 982 | 983 | 984 | 985 | $ 986 | 987 | 988 | 989 | 990 | 991 | 992 | 993 | 994 | 995 | 996 | 997 | 998 | 999 | 1000 | 1001 | 1002 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | require('module-keys/cjs').polyfill(module, require); 21 | 22 | const { SqlFragment } = require('./lib/fragment.js'); 23 | const { SqlId } = require('./lib/id.js'); 24 | const { makeSqlTagFunction } = require('./lib/tag-fn.js'); 25 | const { Mintable } = require('node-sec-patterns'); 26 | 27 | const mintSqlFragment = require.moduleKeys.unbox( 28 | Mintable.minterFor(SqlFragment), 29 | () => true, 30 | String); 31 | 32 | let mysql = null; 33 | let pg = null; // eslint-disable-line id-length 34 | 35 | Object.defineProperties(module.exports, { 36 | mysql: { 37 | // Lazily load MySQL machinery since 38 | // PG users are unlikely to use MySQL and vice-versa. 39 | get() { 40 | if (!mysql) { 41 | // eslint-disable-next-line global-require 42 | const lexer = require('./lib/mysql-lexer.js'); 43 | // eslint-disable-next-line global-require 44 | const { escape, escapeDelimited } = require('./lib/mysql-escaper.js'); 45 | mysql = makeSqlTagFunction( 46 | lexer, escape, escapeDelimited, true, mintSqlFragment); 47 | } 48 | return mysql; 49 | }, 50 | enumerable: true, 51 | }, 52 | pg: { 53 | get() { 54 | if (!pg) { 55 | // eslint-disable-next-line global-require 56 | const lexer = require('./lib/pg-lexer.js'); 57 | // eslint-disable-next-line global-require 58 | const { escape, escapeDelimited } = require('./lib/pg-escaper.js'); 59 | pg = makeSqlTagFunction( 60 | lexer, escape, escapeDelimited, false, mintSqlFragment); 61 | } 62 | return pg; 63 | }, 64 | enumerable: true, 65 | }, 66 | SqlId: { 67 | value: SqlId, 68 | enumerable: true, 69 | }, 70 | SqlFragment: { 71 | value: SqlFragment, 72 | enumerable: true, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /lib/escapers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | /* eslint id-length: 0, complexity: ["error", { "max": 15 }] */ 21 | 22 | const { Mintable } = require('node-sec-patterns'); 23 | const { SqlFragment } = require('./fragment.js'); 24 | const { SqlId } = require('./id.js'); 25 | 26 | const isSqlId = Mintable.verifierFor(SqlId); 27 | const isSqlFragment = Mintable.verifierFor(SqlFragment); 28 | 29 | const iteratorSymbol = Symbol.iterator; 30 | const { isArray } = Array; 31 | const { apply } = Reflect; 32 | const { toString: bufferProtoToString } = Buffer.prototype; 33 | const { isBuffer } = Buffer; 34 | 35 | const CHARS_GLOBAL_REGEXP = /[\0\b\t\n\r\x1a"'\\$]/g; // eslint-disable-line no-control-regex 36 | const TZ_REGEXP = /([+\-\s])(\d\d):?(\d\d)?/; 37 | 38 | function isSeries(val) { 39 | // The typeof val === 'object' check prevents treating strings as series. 40 | // Per (6.1.5.1 Well-Known Symbols), 41 | // "Unless otherwise specified, well-known symbols values are shared by all realms" 42 | // so the iteratorSymbol check below should work cross-realm. 43 | // TODO: It's possible that a function might implement iterator. 44 | return val && typeof val !== 'string' && (isArray(val) || typeof val[iteratorSymbol] === 'function'); 45 | } 46 | 47 | function pad(val, template) { 48 | const str = `${ val >>> 0 }`; // eslint-disable-line no-bitwise 49 | return `${ template.substring(str.length) }${ str }`; 50 | } 51 | 52 | function convertTimezone(tz) { 53 | if (tz === 'Z') { 54 | return 0; 55 | } 56 | 57 | const m = TZ_REGEXP.exec(tz); 58 | if (m) { 59 | // eslint-disable-next-line no-magic-numbers 60 | return (m[1] === '-' ? -1 : 1) * (parseInt(m[2], 10) + ((m[3] ? parseInt(m[3], 10) : 0) / 60)) * 60; 61 | } 62 | return false; 63 | } 64 | 65 | function escapeSeries(series, escapeOne, nests) { 66 | let sql = ''; 67 | 68 | if (isArray(series)) { 69 | for (let i = 0, len = series.length; i < len; ++i) { 70 | const val = series[i]; 71 | if (nests && isSeries(val)) { 72 | sql += `${ (i ? ', (' : '(') }${ escapeSeries(val, escapeOne, true) })`; 73 | } else { 74 | sql += `${ (i ? ', ' : '') }${ escapeOne(val) }`; 75 | } 76 | } 77 | } else { 78 | let wrote = false; 79 | for (const val of series) { 80 | if (nests && isSeries(val)) { 81 | sql += `${ (wrote ? ', (' : '(') }${ escapeSeries(val, escapeOne, true) })`; 82 | } else { 83 | sql += `${ (wrote ? ', ' : '') }${ escapeOne(val) }`; 84 | } 85 | wrote = true; 86 | } 87 | } 88 | 89 | return sql; 90 | } 91 | 92 | function bufferToString(buffer) { 93 | return `X'${ apply(bufferProtoToString, buffer, [ 'hex' ]) }'`; 94 | } 95 | 96 | 97 | function makeEscaper(escapeId, escapeString) { 98 | // eslint-disable-next-line max-params 99 | function formatDate(year, month, day, hour, minute, second, millis) { 100 | // YYYY-MM-DD HH:mm:ss.mmm 101 | return escapeString(`${ pad(year, '0000') }-${ pad(month, '00') }-${ pad(day, '00') } ${ pad(hour, '00') 102 | }:${ pad(minute, '00') }:${ pad(second, '00') }.${ pad(millis, '000') }`); 103 | } 104 | 105 | function dateToString(date, timeZone) { 106 | const dt = new Date(date); 107 | 108 | if (isNaN(dt.getTime())) { 109 | return 'NULL'; 110 | } 111 | 112 | if (timeZone === 'local') { 113 | return formatDate( 114 | dt.getFullYear(), 115 | dt.getMonth() + 1, 116 | dt.getDate(), 117 | dt.getHours(), 118 | dt.getMinutes(), 119 | dt.getSeconds(), 120 | dt.getMilliseconds()); 121 | } 122 | 123 | const tz = convertTimezone(timeZone); 124 | 125 | if (tz !== false && tz !== 0) { 126 | // eslint-disable-next-line no-magic-numbers 127 | dt.setTime(dt.getTime() + (tz * 60000)); 128 | } 129 | 130 | return formatDate( 131 | dt.getUTCFullYear(), 132 | dt.getUTCMonth() + 1, 133 | dt.getUTCDate(), 134 | dt.getUTCHours(), 135 | dt.getUTCMinutes(), 136 | dt.getUTCSeconds(), 137 | dt.getUTCMilliseconds()); 138 | } 139 | 140 | function escape(val, stringifyObjects, timeZone) { 141 | if (val === void 0 || val === null) { 142 | return 'NULL'; 143 | } 144 | 145 | switch (typeof val) { 146 | case 'boolean': 147 | return (val) ? 'true' : 'false'; 148 | case 'number': 149 | return `${ val }`; 150 | case 'object': 151 | break; 152 | default: 153 | return escapeString(val); 154 | } 155 | if (isSqlFragment(val)) { 156 | return val.content; 157 | } 158 | if (isSqlId(val)) { 159 | return escapeId(val.content); 160 | } 161 | if (val instanceof Date) { 162 | return dateToString(val, timeZone || 'local'); 163 | } 164 | if (isBuffer(val)) { 165 | return bufferToString(val); 166 | } 167 | if (isSeries(val)) { 168 | return escapeSeries(val, (element) => escape(element, true, timeZone), true); 169 | } 170 | if (stringifyObjects) { 171 | return escapeString(val.toString()); 172 | } 173 | // eslint-disable-next-line no-use-before-define 174 | return objectToValues(val, timeZone); 175 | } 176 | 177 | function objectToValues(obj, timeZone) { 178 | let sql = ''; 179 | 180 | for (const key in obj) { 181 | const val = obj[key]; 182 | 183 | if (typeof val === 'function') { 184 | continue; 185 | } 186 | 187 | sql += `${ (sql.length === 0 ? '' : ', ') + escapeId(key) } = ${ escape(val, true, timeZone) }`; 188 | } 189 | 190 | return sql; 191 | } 192 | 193 | return escape; 194 | } 195 | 196 | module.exports = Object.freeze({ 197 | CHARS_GLOBAL_REGEXP, 198 | escapeSeries, 199 | isSeries, 200 | isSqlFragment, 201 | makeEscaper, 202 | }); 203 | -------------------------------------------------------------------------------- /lib/fragment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | const { TypedString } = require('template-tag-common'); 21 | 22 | class SqlFragment extends TypedString {} 23 | 24 | Object.defineProperty( 25 | SqlFragment, 26 | 'contractKey', 27 | { 28 | value: 'safesql/fragment', 29 | enumerable: true, 30 | }); 31 | 32 | module.exports.SqlFragment = SqlFragment; 33 | -------------------------------------------------------------------------------- /lib/id.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint no-inline-comments: 0 */ 19 | 20 | 'use strict'; 21 | 22 | require('module-keys/cjs').polyfill(module, require); 23 | 24 | const { TypedString } = require('template-tag-common'); 25 | 26 | class SqlId extends TypedString {} 27 | 28 | Object.defineProperty( 29 | SqlId, 30 | 'contractKey', 31 | { 32 | value: 'safesql/id', 33 | enumerable: true, 34 | }); 35 | 36 | module.exports.SqlId = SqlId; 37 | -------------------------------------------------------------------------------- /lib/mysql-escaper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | const { 21 | CHARS_GLOBAL_REGEXP, 22 | escapeSeries, 23 | isSeries, 24 | isSqlFragment, 25 | makeEscaper, 26 | } = require('./escapers.js'); 27 | 28 | const { toString: bufferProtoToString } = Buffer.prototype; 29 | const { isBuffer } = Buffer; 30 | const { apply } = Reflect; 31 | 32 | const BT_GLOBAL_REGEXP = /`/g; 33 | const QUAL_GLOBAL_REGEXP = /\./g; 34 | const MYSQL_ID_REGEXP = /^`(?:[^`]|``)+`$/; 35 | const MYSQL_QUAL_ID_REGEXP = /^`(?:[^`]|``)+`(?:[.]`(?:[^`]|``)+`)*$/; 36 | 37 | const MYSQL_CHARS_ESCAPE_MAP = { 38 | __proto__: null, 39 | '\0': '\\0', 40 | '\b': '\\b', 41 | '\t': '\\t', 42 | '\n': '\\n', 43 | '\r': '\\r', 44 | // Windows end-of-file 45 | '\x1a': '\\Z', 46 | '"': '\\"', 47 | '$': '\\$', 48 | '\'': '\\\'', 49 | '\\': '\\\\', 50 | }; 51 | 52 | 53 | function mysqlEscapeId(val, forbidQualified) { 54 | if (isSqlFragment(val)) { 55 | const { content } = val; 56 | if ((forbidQualified ? MYSQL_ID_REGEXP : MYSQL_QUAL_ID_REGEXP).test(content)) { 57 | return content; 58 | } 59 | throw new Error(`Expected id, got ${ content }`); 60 | } 61 | if (isSeries(val)) { 62 | return escapeSeries(val, (element) => mysqlEscapeId(element, forbidQualified), false); 63 | } 64 | if (forbidQualified) { 65 | return `\`${ String(val).replace(BT_GLOBAL_REGEXP, '``') }\``; 66 | } 67 | return `\`${ String(val).replace(BT_GLOBAL_REGEXP, '``').replace(QUAL_GLOBAL_REGEXP, '`.`') }\``; 68 | } 69 | 70 | function mysqlEscapeString(val) { 71 | const str = `${ val }`; 72 | 73 | let chunkIndex = 0; 74 | let escapedVal = ''; 75 | 76 | CHARS_GLOBAL_REGEXP.lastIndex = 0; 77 | for (let match; (match = CHARS_GLOBAL_REGEXP.exec(str));) { 78 | escapedVal += str.substring(chunkIndex, match.index) + MYSQL_CHARS_ESCAPE_MAP[match[0]]; 79 | chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex; 80 | } 81 | 82 | if (chunkIndex === 0) { 83 | // Nothing was escaped 84 | return `'${ str }'`; 85 | } 86 | 87 | if (chunkIndex < str.length) { 88 | return `'${ escapedVal }${ str.substring(chunkIndex) }'`; 89 | } 90 | 91 | return `'${ escapedVal }'`; 92 | } 93 | 94 | const mysqlEscape = makeEscaper(mysqlEscapeId, mysqlEscapeString); 95 | 96 | function mysqlEscapeDelimited(value, delimiter, timeZone, forbidQualified) { 97 | if (delimiter === '`') { 98 | return mysqlEscapeId(value, forbidQualified).replace(/^`|`$/g, ''); 99 | } 100 | if (isBuffer(value)) { 101 | value = apply(bufferProtoToString, value, [ 'binary' ]); 102 | } 103 | const escaped = mysqlEscape(String(value), true, timeZone); 104 | return escaped.substring(1, escaped.length - 1); 105 | } 106 | 107 | module.exports = Object.freeze({ 108 | escape: mysqlEscape, 109 | escapeId: mysqlEscapeId, 110 | escapeDelimited: mysqlEscapeDelimited, 111 | }); 112 | -------------------------------------------------------------------------------- /lib/mysql-lexer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | // A simple lexer for MySQL SQL. 21 | // SQL has many divergent dialects with subtly different 22 | // conventions for string escaping and comments. 23 | // This just attempts to roughly tokenize MySQL's specific variant. 24 | // See also 25 | // https://www.w3.org/2005/05/22-SPARQL-MySQL/sql_yacc 26 | // https://github.com/twitter/mysql/blob/master/sql/sql_lex.cc 27 | // https://dev.mysql.com/doc/refman/5.7/en/string-literals.html 28 | 29 | // "--" followed by whitespace starts a line comment 30 | // "#" 31 | // "/*" starts an inline comment ended at first "*/" 32 | // \N means null 33 | // Prefixed strings x'...' is a hex string, b'...' is a binary string, .... 34 | // '...', "..." are strings. `...` escapes identifiers. 35 | // doubled delimiters and backslash both escape 36 | // doubled delimiters work in `...` identifiers 37 | 38 | // eslint-disable-next-line no-use-before-define 39 | exports.makeLexer = makeLexer; 40 | 41 | const WSP = '[\\t\\r\\n ]'; 42 | const PREFIX_BEFORE_DELIMITER = new RegExp( 43 | '^(?:' + 44 | 45 | // Comment 46 | // https://dev.mysql.com/doc/refman/5.7/en/comments.html 47 | // https://dev.mysql.com/doc/refman/5.7/en/ansi-diff-comments.html 48 | // If we do not see a newline at the end of a comment, then it is 49 | // a concatenation hazard; a fragment concatened at the end would 50 | // start in a comment context. 51 | `--(?=${ WSP })[^\\r\\n]*[\r\n]` + 52 | '|#[^\\r\\n]*[\r\n]' + 53 | '|/[*][\\s\\S]*?[*]/' + 54 | '|' + 55 | 56 | // Run of non-comment non-string starts 57 | `(?:[^'"\`\\-/#]|-(?!-${ WSP })|/(?![*]))` + 58 | ')*'); 59 | const DELIMITED_BODIES = { 60 | '\'': /^(?:[^'\\]|\\[\s\S]|'')*/, 61 | '"': /^(?:[^"\\]|\\[\s\S]|"")*/, 62 | '`': /^(?:[^`\\]|\\[\s\S]|``)*/, 63 | }; 64 | 65 | /** 66 | * Template tag that creates a new Error with a message. 67 | * @param {!Array.} strs a valid TemplateObject. 68 | * @return {string} A message suitable for the Error constructor. 69 | */ 70 | function msg(strs, ...dyn) { 71 | let message = String(strs[0]); 72 | for (let i = 0; i < dyn.length; ++i) { 73 | message += JSON.stringify(dyn[i]) + strs[i + 1]; 74 | } 75 | return message; 76 | } 77 | 78 | /** 79 | * Returns a stateful function that can be fed chunks of input and 80 | * which returns a delimiter context. 81 | * 82 | * @return {!function (string) : string} 83 | * a stateful function that takes a string of SQL text and 84 | * returns the context after it. Subsequent calls will assume 85 | * that context. 86 | */ 87 | function makeLexer() { 88 | let errorMessage = null; 89 | let delimiter = null; 90 | return (text) => { 91 | if (errorMessage) { 92 | // Replay the error message if we've already failed. 93 | throw new Error(errorMessage); 94 | } 95 | if (text === null) { 96 | if (delimiter) { 97 | throw new Error( 98 | errorMessage = `Unclosed quoted string: ${ delimiter }`); 99 | } 100 | } 101 | text = String(text); 102 | while (text) { 103 | const pattern = delimiter ? 104 | DELIMITED_BODIES[delimiter] : 105 | PREFIX_BEFORE_DELIMITER; 106 | const match = pattern.exec(text); 107 | // Match must be defined since all possible values of pattern have 108 | // an outer Kleene-* and no postcondition so will fallback to matching 109 | // the empty string. 110 | let nConsumed = match[0].length; 111 | if (text.length > nConsumed) { 112 | const chr = text.charAt(nConsumed); 113 | if (delimiter) { 114 | if (chr === delimiter) { 115 | delimiter = null; 116 | ++nConsumed; 117 | } else { 118 | throw new Error( 119 | errorMessage = msg`Expected ${ chr } at ${ text }`); 120 | } 121 | } else if (Object.hasOwnProperty.call(DELIMITED_BODIES, chr)) { 122 | delimiter = chr; 123 | ++nConsumed; 124 | } else { 125 | throw new Error( 126 | errorMessage = msg`Expected delimiter at ${ text }`); 127 | } 128 | } 129 | text = text.substring(nConsumed); 130 | } 131 | return delimiter; 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /lib/pg-escaper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint id-length: 0, complexity: ["error", { "max": 15 }] */ 19 | 20 | 'use strict'; 21 | 22 | const { 23 | CHARS_GLOBAL_REGEXP, 24 | escapeSeries, 25 | isSeries, 26 | isSqlFragment, 27 | makeEscaper, 28 | } = require('./escapers.js'); 29 | 30 | const { toString: bufferProtoToString } = Buffer.prototype; 31 | const { isBuffer } = Buffer; 32 | const { apply } = Reflect; 33 | 34 | const QUAL_GLOBAL_REGEXP = /\./g; 35 | const PG_ID_REGEXP = /^(?:"(?:[^"]|"")+"|u&"(?:[^"\\]|""|\\.)+")$/i; 36 | const PG_QUAL_ID_REGEXP = /^(?:(?:"(?:[^"]|"")+"|u&"(?:[^"\\]|""|\\.)+")(?:[.](?!$)|$))+$/; 37 | 38 | // Note: NULs are not allowed in text data value. 39 | // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE says 40 | // "The character with the code zero cannot be in a string constant." 41 | // Similarly 42 | // "Quoted identifiers can contain any character, except the character with code zero." 43 | 44 | const PG_CHARS_ESCAPE_MAP = { 45 | __proto__: null, 46 | // See note on NUL above 47 | '\0': '', 48 | '\b': '\b', 49 | '\t': '\t', 50 | '\n': '\n', 51 | '\r': '\r', 52 | '\x1a': '\x1a', 53 | '"': '"', 54 | '$': '$', 55 | '\'': '\'\'', 56 | '\\': '\\', 57 | }; 58 | 59 | const PG_ID_ESCAPE_MAP = { 60 | __proto__: null, 61 | // See note on NUL above 62 | '\0': '', 63 | '\b': '\b', 64 | '\t': '\t', 65 | '\n': '\n', 66 | '\r': '\r', 67 | '\x1a': '\x1a', 68 | '"': '""', 69 | '$': '$', 70 | '\'': '\'', 71 | '\\': '\\', 72 | }; 73 | 74 | const PG_E_CHARS_ESCAPE_MAP = { 75 | __proto__: null, 76 | // See note on NUL above 77 | '\0': '', 78 | '\b': '\\b', 79 | '\t': '\\t', 80 | '\n': '\\n', 81 | '\r': '\\r', 82 | '\x1a': '\\x1a', 83 | '"': '\\"', 84 | '$': '\\$', 85 | // This fails safe when we pick the wrong escaping convention for a 86 | // single-quote delimited string. 87 | // Empirically, from a psql10 client, 88 | // # SELECT e'foo''bar'; 89 | // ?column? 90 | // ---------- 91 | // foo'bar 92 | '\'': '\'\'', 93 | '\\': '\\\\', 94 | }; 95 | 96 | const PG_U_CHARS_ESCAPE_MAP = { 97 | __proto__: null, 98 | // See note on NUL above 99 | '\0': '', 100 | '\b': '\\0008', 101 | '\t': '\\0009', 102 | '\n': '\\000a', 103 | '\r': '\\000d', 104 | '\x1a': '\\001a', 105 | '"': '\\0022', 106 | '$': '\\0024', 107 | '\'': '\\0027', 108 | '\\': '\\005c', 109 | }; 110 | 111 | const HEX_GLOBAL_REGEXP = /[0-9A-Fa-f]/g; 112 | const HEX_TO_BINARY_TABLE = { 113 | __proto__: null, 114 | '0': '0000', 115 | '1': '0001', 116 | '2': '0010', 117 | '3': '0011', 118 | '4': '0100', 119 | '5': '0101', 120 | '6': '0110', 121 | '7': '0111', 122 | '8': '1000', 123 | '9': '1001', 124 | 'A': '1010', 125 | 'B': '1011', 126 | 'C': '1100', 127 | 'D': '1101', 128 | 'E': '1110', 129 | 'F': '1111', 130 | 'a': '1010', 131 | 'b': '1011', 132 | 'c': '1100', 133 | 'd': '1101', 134 | 'e': '1110', 135 | 'f': '1111', 136 | }; 137 | 138 | function hexDigitToBinary(digit) { 139 | return HEX_TO_BINARY_TABLE[digit]; 140 | } 141 | 142 | function hexToBinary(str) { 143 | return str.replace(HEX_GLOBAL_REGEXP, hexDigitToBinary); 144 | } 145 | 146 | function pgEscapeStringBody(str, escapeMap) { 147 | let chunkIndex = 0; 148 | let escapedVal = ''; 149 | 150 | CHARS_GLOBAL_REGEXP.lastIndex = 0; 151 | for (let match; (match = CHARS_GLOBAL_REGEXP.exec(str));) { 152 | escapedVal += str.substring(chunkIndex, match.index) + escapeMap[match[0]]; 153 | chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex; 154 | } 155 | 156 | if (chunkIndex === 0) { 157 | // Nothing was escaped 158 | return str; 159 | } 160 | 161 | if (chunkIndex < str.length) { 162 | escapedVal += str.substring(chunkIndex); 163 | } 164 | 165 | return escapedVal; 166 | } 167 | 168 | function pgEscapeId(val, forbidQualified, unicode) { 169 | if (isSqlFragment(val)) { 170 | const { content } = val; 171 | if ((forbidQualified ? PG_ID_REGEXP : PG_QUAL_ID_REGEXP).test(content)) { 172 | return content; 173 | } 174 | throw new Error(`Expected id, got ${ content }`); 175 | } 176 | if (isSeries(val)) { 177 | return escapeSeries(val, (element) => pgEscapeId(element, forbidQualified, unicode), false); 178 | } 179 | let escaped = unicode ? 180 | pgEscapeStringBody(`${ val }`, PG_U_CHARS_ESCAPE_MAP) : 181 | pgEscapeStringBody(`${ val }`, PG_ID_ESCAPE_MAP); 182 | if (!forbidQualified) { 183 | escaped = escaped.replace(QUAL_GLOBAL_REGEXP, unicode ? '".u&"' : '"."'); 184 | } 185 | return `${ unicode ? 'u&"' : '"' }${ escaped }"`; 186 | } 187 | 188 | const PG_ID_DELIMS_REGEXP = /^(?:[Uu]&)?"|"$/g; 189 | 190 | function pgEscapeString(val) { 191 | const str = `${ val }`; 192 | 193 | const escapedVal = pgEscapeStringBody(val, PG_E_CHARS_ESCAPE_MAP); 194 | 195 | if (escapedVal === str) { 196 | return `'${ escapedVal }'`; 197 | } 198 | 199 | // If there are any backslashes or quotes, we use e'...' style strings since 200 | // those allow a consistent scheme for escaping all string meta-characters so entail 201 | // the fewest assumptions. 202 | return `e'${ escapedVal }'`; 203 | } 204 | 205 | const pgEscape = makeEscaper(pgEscapeId, pgEscapeString); 206 | 207 | function pgEscapeDelimitedString(strValue, delimiter) { 208 | switch (delimiter) { 209 | case '\'': 210 | case 'b\'': 211 | case 'x\'': 212 | return pgEscapeStringBody(strValue, PG_CHARS_ESCAPE_MAP); 213 | case 'e\'': 214 | return pgEscapeStringBody(strValue, PG_E_CHARS_ESCAPE_MAP); 215 | case 'e': 216 | return `'${ pgEscapeStringBody(strValue, PG_E_CHARS_ESCAPE_MAP) }'`; 217 | case 'u&\'': 218 | return pgEscapeStringBody(strValue, PG_U_CHARS_ESCAPE_MAP); 219 | default: 220 | break; 221 | } 222 | 223 | if (delimiter[0] === '$' && delimiter.indexOf('$', 1) === delimiter.length - 1) { 224 | // Handle literal strings like $tag$...$tag$ 225 | let embedHazard = strValue.indexOf(delimiter) >= 0; 226 | if (!embedHazard) { 227 | const lastDollar = strValue.lastIndexOf('$'); 228 | if (lastDollar >= 0) { 229 | const tail = strValue.substring(lastDollar); 230 | embedHazard = (tail === delimiter.substring(0, tail.length)); 231 | } 232 | } 233 | if (embedHazard) { 234 | throw new Error(`Cannot embed ${ JSON.stringify(strValue) } between ${ delimiter }`); 235 | } 236 | return strValue; 237 | } 238 | throw new Error(`Cannot escape with ${ delimiter }`); 239 | } 240 | 241 | function pgEscapeDelimited(value, delimiter, timeZone, forbidQualified) { 242 | if (delimiter === '"') { 243 | return pgEscapeId(value, forbidQualified, false).replace(PG_ID_DELIMS_REGEXP, ''); 244 | } else if (delimiter === 'u&"') { 245 | return pgEscapeId(value, forbidQualified, true).replace(PG_ID_DELIMS_REGEXP, ''); 246 | } 247 | 248 | let strValue = value; 249 | if (isBuffer(value)) { 250 | const wantsBinaryDigits = delimiter === 'b\''; 251 | const encoding = wantsBinaryDigits || delimiter === 'x\'' ? 'hex' : 'binary'; 252 | strValue = apply(bufferProtoToString, value, [ encoding ]); 253 | if (wantsBinaryDigits) { 254 | // encoding='binary' to buffer means something very different from binary 255 | // encoding in PGSql. 256 | strValue = hexToBinary(strValue); 257 | } 258 | } 259 | return pgEscapeDelimitedString(`${ strValue }`, delimiter); 260 | } 261 | 262 | module.exports = Object.freeze({ 263 | escape: pgEscape, 264 | escapeId: pgEscapeId, 265 | escapeDelimited: pgEscapeDelimited, 266 | }); 267 | -------------------------------------------------------------------------------- /lib/pg-lexer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | // A simple lexer for Postgres SQL. 21 | // 22 | // https://www.postgresql.org/docs/9.0/static/sql-syntax-lexical.html 23 | // 24 | // -- line chars line comment 25 | // /* block */ block comment. may nest: /* /* */ still in comment */ 26 | // 27 | // "..." identifier literal 28 | // U&"..." identifier literal with unicode escapes 29 | // UESCAPE symbol may follow U& string to override \ as escape character 30 | // 31 | // '...' string literal 32 | // E'...' supports C-style escape sequences 33 | // U&'...' string literal with unicode escapes 34 | // UESCAPE symbol ditto 35 | // B'...' binary literal 36 | // X'...' hex literal 37 | // 38 | // $$...$$ string literal with no escaping convention 39 | // $foo$...$foo$ string literal where "foo" may be any run of identifier chars 40 | 41 | 42 | // eslint-disable-next-line no-use-before-define 43 | exports.makeLexer = makeLexer; 44 | 45 | 46 | const TOP_LEVEL_DELIMITER = new RegExp( 47 | // Line comment 48 | '--' + 49 | // or a block comment start 50 | '|/[*]' + 51 | // or an unescaped string start 52 | // Tag has the form of an unquoted identifier without embedded '$'. 53 | // TODO: should allow non-ascii identifiers. Might need to normalize. 54 | '|[$](?:[a-zA-Z_][a-zA-Z_0-9]*)?[$]' + 55 | // or an identifier start 56 | '|(?:[Uu]&)?"' + 57 | // or an escaped string start 58 | '|(?:[Uu]&|[EeBbXx])?\''); 59 | 60 | const LINE_COMMENT_BODY = /^[^\r\n]*/; 61 | 62 | const BLOCK_COMMENT_TOKEN = /[*][/]|[/][*]/; 63 | 64 | const ESC_DQ_STRING_BODY = /^(?:[^"\\]|""|\\.)*(")?/; 65 | const ESC_SQ_STRING_BODY = /^(?:[^'\\]|''|\\.)*(')?/; 66 | 67 | const SIMPLE_DQ_STRING_BODY = /^(?:[^"]|"")*(")?/; 68 | const SIMPLE_SQ_STRING_BODY = /^(?:[^']|'')*(')?/; 69 | 70 | const ESC_STRING_CONTINUATION = /^[\t\n\r ]*([/][*]|--|')?/; 71 | 72 | const STRING_BODIES = { 73 | __proto__: null, 74 | '"': SIMPLE_DQ_STRING_BODY, 75 | 'u&"': ESC_DQ_STRING_BODY, 76 | '\'': SIMPLE_SQ_STRING_BODY, 77 | 'b\'': SIMPLE_SQ_STRING_BODY, 78 | 'e\'': ESC_SQ_STRING_BODY, 79 | 'u&\'': ESC_SQ_STRING_BODY, 80 | 'x\'': SIMPLE_SQ_STRING_BODY, 81 | }; 82 | 83 | const LAST_DELIMITER_CHARACTER_TO_HANDLER = { 84 | '-': (delimiter, chunk) => { 85 | // delimiter is -- 86 | const match = LINE_COMMENT_BODY.exec(chunk); 87 | const remainder = chunk.substring(match[0].length); 88 | if (remainder) { 89 | return [ null, remainder ]; 90 | } 91 | throw new Error(`Unterminated line comment: --${ chunk }`); 92 | }, 93 | '*': (delimiter, chunk) => { 94 | // delimiter is '/*'. 95 | let depth = delimiter.length / 2; 96 | let remainder = chunk; 97 | while (remainder) { 98 | const match = BLOCK_COMMENT_TOKEN.exec(remainder); 99 | if (!match) { 100 | break; 101 | } 102 | remainder = remainder.substring(match.index + 2); 103 | if (match[0] === '/*') { 104 | ++depth; 105 | } else { 106 | // */ 107 | --depth; 108 | if (!depth) { 109 | break; 110 | } 111 | } 112 | } 113 | if (depth) { 114 | throw new Error(`Unterminated block comment: /*${ chunk }`); 115 | } 116 | return [ null, remainder ]; 117 | 118 | // TODO: Do we need to take into account nested "--". 119 | // soc.if.usp.br/manual/postgresql-doc-7.4/html/plpgsql-structure.html says 120 | // "double dash comments can be enclosed into a block comment and 121 | // a double dash can hide the block comment delimiters /* and */." 122 | }, 123 | '"': (delimiter, chunk) => { 124 | const match = STRING_BODIES[delimiter].exec(chunk); 125 | const remainder = chunk.substring(match[0].length); 126 | if (match[1]) { 127 | return [ null, remainder ]; 128 | } 129 | if (match[0]) { 130 | return [ delimiter, remainder ]; 131 | } 132 | throw new Error(`Incomplete escape sequence in ${ delimiter } delimited string at \`${ chunk }\``); 133 | }, 134 | '\'': (delimiter, chunk) => { 135 | const match = STRING_BODIES[delimiter].exec(chunk); 136 | const remainder = chunk.substring(match[0].length); 137 | if (match[1]) { 138 | return [ 139 | // 4.1.2.2. String Constants with C-style Escapes 140 | // (When continuing an escape string constant across lines, 141 | // write E only before the first opening quote.) 142 | (delimiter === 'e\'' || delimiter === 'E\'') ? 'e' : null, // eslint-disable-line array-element-newline 143 | remainder, 144 | ]; 145 | } 146 | if (match[0]) { 147 | return [ delimiter, remainder ]; 148 | } 149 | throw new Error(`Incomplete escape sequence in ${ delimiter } delimited string at \`${ chunk }\``); 150 | }, 151 | '$': (delimiter, chunk) => { 152 | // TODO: should this match be case insensitive? $x$...$X$ 153 | const i = chunk.indexOf(delimiter); 154 | if (i >= 0) { 155 | return [ null, chunk.substring(i + delimiter.length) ]; 156 | } 157 | const lastDollar = chunk.lastIndexOf('$'); 158 | if (lastDollar >= 0) { 159 | const suffix = chunk.substring(lastDollar); 160 | if (delimiter.indexOf(suffix) === 0) { 161 | // merge hazard 162 | throw new Error(`merge hazard '${ suffix }' at end of ${ delimiter } delimited string`); 163 | } 164 | } 165 | return [ delimiter, '' ]; 166 | }, 167 | // Special handler to detect e'...' continuations. See 'e' case above. 168 | 'e': (delimiter, chunk) => { 169 | let remainder = chunk; 170 | while (remainder) { 171 | const match = ESC_STRING_CONTINUATION.exec(remainder); 172 | let [ consumed, subdelim ] = match; // eslint-disable-line prefer-const 173 | if (!consumed) { 174 | return [ null, remainder ]; 175 | } 176 | remainder = remainder.substring(consumed.length); 177 | if (subdelim) { 178 | if (subdelim === '\'') { 179 | return [ 'e\'', remainder ]; 180 | } 181 | while (remainder && subdelim) { 182 | const handler = LAST_DELIMITER_CHARACTER_TO_HANDLER[subdelim[subdelim.length - 1]]; 183 | [ subdelim, remainder ] = handler(subdelim, remainder); 184 | } 185 | } 186 | } 187 | return [ delimiter, remainder ]; 188 | }, 189 | }; 190 | 191 | function replayError(fun) { 192 | let message = null; 193 | return (...args) => { 194 | if (message !== null) { 195 | throw new Error(message); 196 | } 197 | try { 198 | return fun(...args); 199 | } catch (exc) { 200 | message = `${ exc.message }`; 201 | throw exc; 202 | } 203 | }; 204 | } 205 | 206 | function makeLexer() { 207 | let delimiter = null; 208 | let continuationAmbiguity = false; 209 | let chunkIndex = -1; 210 | 211 | function consumeFromLeft(remainder) { 212 | if (delimiter) { 213 | const lastChar = delimiter[delimiter.length - 1]; 214 | if (lastChar !== '*' && lastChar !== '-') { 215 | continuationAmbiguity = false; 216 | } 217 | const handler = LAST_DELIMITER_CHARACTER_TO_HANDLER[lastChar]; 218 | ([ delimiter, remainder ] = handler(delimiter, remainder)); 219 | } else { 220 | const match = TOP_LEVEL_DELIMITER.exec(remainder); 221 | if (continuationAmbiguity) { 222 | const end = match ? match.index : remainder.length; 223 | if (/[^\t\n\r ]/.test(remainder.substring(0, end))) { 224 | continuationAmbiguity = false; 225 | } 226 | } 227 | if (!match) { 228 | return ''; 229 | } 230 | [ delimiter ] = match; 231 | if (delimiter[0] !== '$') { 232 | // Empirically, 233 | // postgres=# SELECT $foo$bar$Foo$; 234 | // postgres$# $foo$; 235 | // ?column? 236 | // ----------- 237 | // bar$Foo$;+ 238 | delimiter = delimiter.toLowerCase(); 239 | } 240 | remainder = remainder.substring(match.index + delimiter.length); 241 | } 242 | return remainder; 243 | } 244 | 245 | function lexer(chunk) { 246 | if (chunk === null) { 247 | if (delimiter && delimiter !== 'e') { 248 | throw new Error(`Unclosed quoted string: ${ delimiter }`); 249 | } 250 | return delimiter; 251 | } 252 | 253 | ++chunkIndex; 254 | 255 | if (continuationAmbiguity && chunkIndex > 1) { 256 | // If any chunk besides the last contains a newline and 257 | // does not contain any non-whitespace or comment content, 258 | // then we have a continuation ambiguity. 259 | // 260 | // For example, 261 | // pg`SELECT ${ x } 262 | // ${ y }` 263 | // then we would have to know how ${ x } was escaped to 264 | // determine how to escape ${ y } because of a string 265 | // continuation corner-case: 266 | // 267 | // From https://www.postgresql.org/docs/9.0/static/sql-syntax-lexical.html 268 | // "Two string constants that are only separated by whitespace with at 269 | // least one newline are concatenated and effectively treated as if the 270 | // string had been written as one constant." 271 | // 272 | // "When continuing an escape string constant across lines, write E only 273 | // before the first opening quote." 274 | // 275 | // To decide whether to wrap y using e'...' ${ y } we need to know about 276 | // ${ x }. 277 | throw new Error( 278 | // eslint-disable-next-line no-template-curly-in-string 279 | 'Potential for ambiguous string continuation at `${ chunk }`.' + 280 | ' If you need string continuation start with an e\'...\' string.'); 281 | } 282 | 283 | let remainder = `${ chunk }`; 284 | continuationAmbiguity = /[\n\r]/.test(chunk); 285 | while (remainder) { 286 | remainder = consumeFromLeft(remainder); 287 | } 288 | return delimiter; 289 | } 290 | 291 | return replayError(lexer); 292 | } 293 | 294 | module.exports.makeLexer = makeLexer; 295 | -------------------------------------------------------------------------------- /lib/tag-fn.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | const { 21 | memoizedTagFunction, 22 | trimCommonWhitespaceFromLines, 23 | } = require('template-tag-common'); 24 | 25 | const LITERAL_BACKTICK_FIXUP_PATTERN = /((?:[^\\]|\\[^`])+)|\\(`)(?!`)/g; 26 | 27 | /** 28 | * Trims common whitespace and converts escaped backticks 29 | * to backticks as appropriate. 30 | * 31 | * @param {!Array.} strings a valid TemplateObject. 32 | * @return {!Array.} the adjusted raw strings. 33 | */ 34 | function prepareStrings(strings) { 35 | const raw = trimCommonWhitespaceFromLines(strings).raw.slice(); 36 | for (let i = 0, len = raw.length; i < len; ++i) { 37 | // Convert \` to ` but leave \\` alone. 38 | raw[i] = raw[i].replace(LITERAL_BACKTICK_FIXUP_PATTERN, '$1$2'); 39 | } 40 | return raw; 41 | } 42 | 43 | /** 44 | * Returns a template tag function that contextually autoescapes values 45 | * producing a SqlFragment. 46 | */ 47 | function makeSqlTagFunction( 48 | { makeLexer }, 49 | escape, 50 | escapeDelimitedValue, 51 | fixupBackticks, 52 | decorateOutput) { 53 | /** 54 | * Analyzes the static parts of the tag content. 55 | * 56 | * @param {!Array.} strings a valid TemplateObject. 57 | * @return { !{ 58 | * delimiters : !Array., 59 | * chunks: !Array. 60 | * } } 61 | * A record like { delimiters, chunks } 62 | * where delimiter is a contextual cue and chunk is 63 | * the adjusted raw text. 64 | */ 65 | function computeStatic(strings) { 66 | const chunks = fixupBackticks ? prepareStrings(strings) : strings.raw; 67 | const lexer = makeLexer(); 68 | 69 | const delimiters = []; 70 | for (let i = 0, len = chunks.length; i < len; ++i) { 71 | const chunk = String(chunks[i]); 72 | delimiters.push(lexer(chunk)); 73 | } 74 | 75 | // Signal end of input. 76 | lexer(null); 77 | 78 | return { delimiters, chunks }; 79 | } 80 | 81 | function defangMergeHazard(before, escaped, after) { 82 | const escapedLast = escaped[escaped.length - 1]; 83 | if ('"\'`'.indexOf(escapedLast) < 0) { 84 | // Not a merge hazard. 85 | return escaped; 86 | } 87 | 88 | let escapedSetOff = escaped; 89 | const lastBefore = before[before.length - 1]; 90 | if (escapedLast === escaped[0] && escapedLast === lastBefore) { 91 | escapedSetOff = ` ${ escapedSetOff }`; 92 | } 93 | if (escapedLast === after[0]) { 94 | escapedSetOff += ' '; 95 | } 96 | return escapedSetOff; 97 | } 98 | 99 | function interpolateSqlIntoFragment( 100 | { stringifyObjects, timeZone, forbidQualified }, 101 | { delimiters, chunks }, 102 | strings, values) { 103 | // A buffer to accumulate output. 104 | let [ result ] = chunks; 105 | for (let i = 1, len = chunks.length; i < len; ++i) { 106 | const chunk = chunks[i]; 107 | // The count of values must be 1 less than the surrounding 108 | // chunks of literal text. 109 | const delimiter = delimiters[i - 1]; 110 | const value = values[i - 1]; 111 | 112 | const escaped = delimiter ? 113 | escapeDelimitedValue(value, delimiter, timeZone, forbidQualified) : 114 | defangMergeHazard( 115 | result, 116 | escape(value, stringifyObjects, timeZone), 117 | chunk); 118 | 119 | result += escaped + chunk; 120 | } 121 | 122 | return decorateOutput(result); 123 | } 124 | 125 | return memoizedTagFunction(computeStatic, interpolateSqlIntoFragment); 126 | } 127 | 128 | module.exports.makeSqlTagFunction = makeSqlTagFunction; 129 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "safesql", 3 | "description": "string template tags for safely composing MySQL and PostgreSQL query strings", 4 | "keywords": [ 5 | "sql", 6 | "security", 7 | "injection", 8 | "template", 9 | "template-tag", 10 | "string-template", 11 | "sec-roadmap", 12 | "es6" 13 | ], 14 | "version": "2.0.2", 15 | "main": "index.js", 16 | "files": [ 17 | "fragment.js", 18 | "id.js", 19 | "index.js", 20 | "lib/*.js" 21 | ], 22 | "mintable": { 23 | "selfNominate": [ 24 | "safesql/fragment", 25 | "safesql/id" 26 | ] 27 | }, 28 | "scripts": { 29 | "cover": "istanbul cover _mocha", 30 | "coveralls": "npm run cover -- --report lcovonly && cat ./coverage/lcov.info | coveralls", 31 | "lint": "./node_modules/.bin/eslint .", 32 | "prepack": "npm run lint && npm test && ./scripts/make-md-toc.pl README.md", 33 | "test": "mocha" 34 | }, 35 | "pre-commit": [ 36 | "prepack" 37 | ], 38 | "author": "@mikesamuel", 39 | "license": "Apache-2.0", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/mikesamuel/safesql.git" 43 | }, 44 | "bugs": { 45 | "url": "https://github.com/mikesamuel/safesql/issues" 46 | }, 47 | "dependencies": { 48 | "template-tag-common": "^5.0.2" 49 | }, 50 | "devDependencies": { 51 | "chai": "^4.1.2", 52 | "coveralls": "^3.0.1", 53 | "eslint": "^4.19.1", 54 | "eslint-config-strict": "^14.0.1", 55 | "istanbul": "^0.4.5", 56 | "mocha": "^4.0.1", 57 | "mocha-lcov-reporter": "^1.3.0", 58 | "pre-commit": "^1.2.2" 59 | }, 60 | "eslintIgnore": [ 61 | "/coverage/**" 62 | ], 63 | "eslintConfig": { 64 | "extends": [ 65 | "strict" 66 | ], 67 | "parserOptions": { 68 | "ecmaVersion": 6, 69 | "sourceType": "source", 70 | "ecmaFeatures": { 71 | "impliedStrict": false 72 | } 73 | }, 74 | "rules": { 75 | "no-warning-comments": [ 76 | "error", 77 | { 78 | "terms": [ 79 | "do not submit" 80 | ] 81 | } 82 | ], 83 | "no-void": "off", 84 | "strict": [ 85 | "error", 86 | "global" 87 | ] 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scripts/make-md-toc.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | 5 | foreach my $path (@ARGV) { 6 | open (my $IN, "<$path") or die "$path: $!"; 7 | my %ids = (); 8 | my @toc = (); 9 | my $content = ""; 10 | 11 | my $lastDepth = 0; 12 | while (<$IN>) { 13 | if (m/^(\#{2,})(.*?)<\/a>\s*$/) { 14 | my $depth = length($1) - 1; 15 | my $text = $2; 16 | my $id = $3; 17 | if (exists($ids{$id})) { 18 | die "$path:$.: Heading id $id previously seen at $ids{$id}"; 19 | } else { 20 | $ids{$id} = $.; 21 | } 22 | if ($depth > $lastDepth + 1) { 23 | die "$path:$.: Heading id $id has depth $depth which skips levels from $lastDepth"; 24 | } 25 | $text =~ s/^\s*|\s*$//g; 26 | push(@toc, (" " x ($depth - 1)) . "* [$text](#$id)\n"); 27 | $lastDepth = $depth; 28 | } elsif (m/^##/) { 29 | die "$path:$.: Heading lacks identifier"; 30 | } 31 | $content .= $_; 32 | } 33 | 34 | close ($IN) or die "$path: $!"; 35 | 36 | my $toc = join("", @toc); 37 | unless ($content =~ s/(\n\n).*?(\n\n)/$1\n$toc$2/s) { 38 | die "$path: Cannot find delimited space for the table of contents"; 39 | } 40 | 41 | my $outpath = "$path.out"; 42 | open (my $OUT, ">$outpath") or die "$path: $!"; 43 | print $OUT "$content"; 44 | close ($OUT) or die "$path: $!"; 45 | 46 | rename($outpath, $path) or die "$path: Failed to rename $outpath to $path $!"; 47 | } 48 | -------------------------------------------------------------------------------- /scripts/prepublish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | [ -n "$TMPDIR" ] 6 | [ -d "$TMPDIR" ] 7 | 8 | export PROJECT_NAME=$(node -e 'console.log(require("./package.json").name)') 9 | export TMP_WORKSPACE="$TMPDIR"/"$PROJECT_NAME"-test-workspace 10 | 11 | rm -rf "$TMP_WORKSPACE" 12 | mkdir -p "$TMP_WORKSPACE"/package 13 | 14 | # Repack, and check the contents 15 | export TARBALL="$(npm pack 2> /dev/null | tail -1)" 16 | 17 | echo PACKAGE CONTENTS: 18 | tar tfz "$TARBALL" 19 | 20 | read -p 'Does the package contents look ok? (yes|no) ' PACKED_OK 21 | 22 | echo "$PACKED_OK" | egrep -qi '^y' 23 | 24 | 25 | # Test that it installs and tests run in isolation 26 | cp "$TARBALL" "$TMP_WORKSPACE"/ 27 | cp -r test/ "$TMP_WORKSPACE"/package/test/ 28 | pushd "$TMP_WORKSPACE" 29 | tar xfz "$TARBALL" && ( 30 | pushd "$TMP_WORKSPACE"/package 31 | npm install \ 32 | && npm run lint \ 33 | && npm run cover 34 | popd >& /dev/null 35 | ) 36 | popd >& /dev/null 37 | 38 | rm "$TARBALL" 39 | 40 | 41 | echo ' 42 | 1. Figure out what kind of release it is: 43 | * patch 44 | * minor 45 | * major 46 | 47 | Assuming it is in `$NPM_VERSION_BUMP`: 48 | $ npm version "$NPM_VERSION_BUMP" 49 | 50 | 51 | 2. Get a 2FA nonce from the Google Authenticator app. 52 | Assuming it is in `$OTP`: 53 | $ npm publish --otp "$OTP" 54 | 55 | 56 | 3. Push the release label to GitHub. 57 | $ git push --tags origin master 58 | ' 59 | -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | NODE_MAJOR_VERSION="$(node -v | perl -ne 'print $1 if m/^v?(\d+)[.]/')" 6 | [ -n "$NODE_MAJOR_VERSION" ] 7 | 8 | if [[ "$NODE_MAJOR_VERSION" -gt 7 ]]; then 9 | # Standard fails on node 7 when run on travis-ci due to some odd 10 | # interaction between standard and an eslint plugin. We really 11 | # only need to run the linter on one platform. 12 | npm run-script lint 13 | else 14 | echo Skipping linter on node v"$NODE_MAJOR_VERSION" 15 | fi 16 | npm test 17 | -------------------------------------------------------------------------------- /test/escapers-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint "id-length": 0, "id-blacklist": 0, "no-magic-numbers": 0 */ 19 | 20 | 'use strict'; 21 | 22 | require('module-keys/cjs').polyfill(module, require, 'safesql/test/escaper-test.js'); 23 | 24 | const { expect } = require('chai'); 25 | const { describe, it } = require('mocha'); 26 | 27 | const { mysql, pg } = require('../index.js'); 28 | const mysqlEscaper = require('../lib/mysql-escaper.js'); 29 | const pgEscaper = require('../lib/pg-escaper.js'); 30 | const escapers = { 31 | mysql: mysqlEscaper, 32 | pg: pgEscaper, 33 | }; 34 | 35 | describe('escapers', () => { 36 | for (const target of [ 'mysql', 'pg' ]) { 37 | // eslint-disable-next-line no-use-before-define 38 | describe(target, () => testEscapes(target, escapers[target])); 39 | } 40 | }); 41 | 42 | function testEscapes(target, { escape, escapeId }) { 43 | describe('escapeId', () => { 44 | it('value is quoted', () => { 45 | expect(escapeId('id')).to.equal( 46 | { 47 | mysql: '`id`', 48 | pg: '"id"', 49 | }[target]); 50 | }); 51 | 52 | it('value can be a number', () => { 53 | expect(escapeId(42)).to.equal({ 54 | mysql: '`42`', 55 | pg: '"42"', 56 | }[target]); 57 | }); 58 | 59 | it('value can be an object', () => { 60 | expect(escapeId({})).to.equal({ 61 | mysql: '`[object Object]`', 62 | pg: '"[object Object]"', 63 | }[target]); 64 | }); 65 | 66 | it('value toString is called', () => { 67 | expect(escapeId({ toString() { 68 | return 'foo'; 69 | } })).to.equal({ 70 | mysql: '`foo`', 71 | pg: '"foo"', 72 | }[target]); 73 | }); 74 | 75 | it('value toString is quoted', () => { 76 | expect(escapeId({ 77 | toString() { 78 | return 'f`"oo'; 79 | }, 80 | })).to.equal({ 81 | mysql: '`f``"oo`', 82 | pg: '"f`""oo"', 83 | }[target]); 84 | }); 85 | 86 | it('value containing escapes is quoted', () => { 87 | expect(escapeId('i`"d')).to.equal({ 88 | mysql: '`i``"d`', 89 | pg: '"i`""d"', 90 | }[target]); 91 | }); 92 | 93 | it('value containing separator is quoted', () => { 94 | expect(escapeId('id1.id2')).to.equal({ 95 | mysql: '`id1`.`id2`', 96 | pg: '"id1"."id2"', 97 | }[target]); 98 | }); 99 | 100 | it('value containing separator and escapes is quoted', () => { 101 | expect(escapeId('id`1.i"d2')).to.equal({ 102 | mysql: '`id``1`.`i"d2`', 103 | pg: '"id`1"."i""d2"', 104 | }[target]); 105 | }); 106 | 107 | it('value containing separator is fully escaped when forbidQualified', () => { 108 | expect(escapeId('id1.id2', true)).to.equal({ 109 | mysql: '`id1.id2`', 110 | pg: '"id1.id2"', 111 | }[target]); 112 | }); 113 | 114 | it('arrays are turned into lists', () => { 115 | expect(escapeId([ 'a', 'b', 't.c' ])).to.equal({ 116 | mysql: '`a`, `b`, `t`.`c`', 117 | pg: '"a", "b", "t"."c"', 118 | }[target]); 119 | }); 120 | 121 | it('nested arrays are flattened', () => { 122 | expect(escapeId([ 'a', [ 'b', [ 't.c' ] ] ])).to.equal({ 123 | mysql: '`a`, `b`, `t`.`c`', 124 | pg: '"a", "b", "t"."c"', 125 | }[target]); 126 | }); 127 | 128 | describe('qualified id to escapeId', () => { 129 | const qualifiedId = { 130 | mysql: mysql`\`id1\`.\`id2\``, 131 | pg: pg`"id1"."id2"`, 132 | }[target]; 133 | it('rejects', () => { 134 | expect(() => escapeId(qualifiedId, true)).to.throw(); 135 | }); 136 | it('allow', () => { 137 | expect(() => escapeId(qualifiedId, false)).to.not.throw(); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('escape', () => { 143 | it('undefined -> NULL', () => { 144 | expect(escape(void 0)).to.equal('NULL'); 145 | }); 146 | 147 | it('null -> NULL', () => { 148 | expect(escape(null)).to.equal('NULL'); 149 | }); 150 | 151 | it('booleans convert to strings', () => { 152 | expect(escape(false)).to.equal('false'); 153 | expect(escape(true)).to.equal('true'); 154 | }); 155 | 156 | it('numbers convert to strings', () => { 157 | expect(escape(5)).to.equal('5'); 158 | }); 159 | 160 | it('raw not escaped', () => { 161 | expect(escape(mysql`NOW()`)).to.equal('NOW()'); 162 | }); 163 | 164 | it('objects are turned into key value pairs', () => { 165 | expect(escape({ a: 'b', c: 'd' })).to.equal({ 166 | mysql: '`a` = \'b\', `c` = \'d\'', 167 | pg: '"a" = \'b\', "c" = \'d\'', 168 | }[target]); 169 | }); 170 | 171 | it('objects function properties are ignored', () => { 172 | // eslint-disable-next-line no-empty-function 173 | expect(escape({ a: 'b', c() {} })).to.equal({ 174 | mysql: '`a` = \'b\'', 175 | pg: '"a" = \'b\'', 176 | }[target]); 177 | }); 178 | 179 | it('nested toSqlString is not trusted', () => { 180 | expect(escape({ id: { toSqlString() { 181 | return 'LAST_INSERT_ID()'; 182 | } } })).to.equal({ 183 | mysql: '`id` = \'[object Object]\'', 184 | pg: '"id" = \'[object Object]\'', 185 | }[target]); 186 | }); 187 | 188 | it('objects toSqlString is not trusted', () => { 189 | expect(escape({ toSqlString() { 190 | return '@foo_id'; 191 | } })).to.equal(''); 192 | }); 193 | 194 | it('fragment is not quoted', () => { 195 | expect(escape(mysql`CURRENT_TIMESTAMP()`)).to.equal('CURRENT_TIMESTAMP()'); 196 | }); 197 | 198 | it('nested objects are cast to strings', () => { 199 | expect(escape({ a: { nested: true } })).to.equal({ 200 | mysql: '`a` = \'[object Object]\'', 201 | pg: '"a" = \'[object Object]\'', 202 | }[target]); 203 | }); 204 | 205 | it('nested objects use toString', () => { 206 | expect(escape({ a: { toString() { 207 | return 'foo'; 208 | } } })).to.equal( 209 | { 210 | mysql: '`a` = \'foo\'', 211 | pg: '"a" = \'foo\'', 212 | }[target]); 213 | }); 214 | 215 | it('nested objects use toString is quoted', () => { 216 | expect(escape({ a: { toString() { 217 | return 'f\'oo'; 218 | } } })).to.equal({ 219 | mysql: '`a` = \'f\\\'oo\'', 220 | pg: '"a" = e\'f\'\'oo\'', 221 | }[target]); 222 | }); 223 | 224 | it('arrays are turned into lists', () => { 225 | expect(escape([ 1, 2, 'c' ])).to.equal('1, 2, \'c\''); 226 | }); 227 | 228 | it('series are turned into lists', () => { 229 | function * items() { 230 | yield 1; 231 | yield 2; 232 | yield 'c'; 233 | } 234 | expect(escape(items())).to.equal('1, 2, \'c\''); 235 | }); 236 | 237 | it('nested arrays are turned into grouped lists', () => { 238 | function * items() { 239 | yield [ 1, 2, 3 ]; 240 | yield ( 241 | function * nested() { 242 | yield 4; 243 | yield 5; 244 | yield 6; 245 | }()); 246 | yield [ 'a', 'b', { nested: true } ]; 247 | } 248 | 249 | expect(escape(items())).to.equal('(1, 2, 3), (4, 5, 6), (\'a\', \'b\', \'[object Object]\')'); 250 | }); 251 | 252 | it('nested series are turned into grouped lists', () => { 253 | function * items() { 254 | yield 4; 255 | yield 5; 256 | yield 6; 257 | } 258 | expect(escape([ [ 1, 2, 3 ], items(), [ 'a', 'b', { nested: true } ] ])) 259 | .to.equal('(1, 2, 3), (4, 5, 6), (\'a\', \'b\', \'[object Object]\')'); 260 | }); 261 | 262 | it('nested objects inside arrays are cast to strings', () => { 263 | expect(escape([ 1, { nested: true }, 2 ])).to.equal('1, \'[object Object]\', 2'); 264 | }); 265 | 266 | it('nested objects inside arrays use toString', () => { 267 | expect(escape([ 268 | 1, 269 | { toString() { 270 | return 'foo'; 271 | } }, 272 | 2, 273 | ])).to.equal('1, \'foo\', 2'); 274 | }); 275 | 276 | it('strings are quoted', () => { 277 | expect(escape('Super')).to.equal('\'Super\''); 278 | }); 279 | 280 | it('\\0 gets escaped', () => { 281 | expect(escape('Sup\u0000er')).to.equal({ 282 | mysql: '\'Sup\\0er\'', 283 | pg: 'e\'Super\'', 284 | }[target]); 285 | 286 | expect(escape('Super\u0000')).to.equal({ 287 | mysql: '\'Super\\0\'', 288 | pg: 'e\'Super\'', 289 | }[target]); 290 | 291 | expect(escape('Super\u000012')).to.equal({ 292 | mysql: '\'Super\\012\'', 293 | pg: 'e\'Super12\'', 294 | }[target]); 295 | }); 296 | 297 | it('\\b gets escaped', () => { 298 | expect(escape('Sup\ber')).to.equal({ 299 | mysql: '\'Sup\\ber\'', 300 | pg: 'e\'Sup\\ber\'', 301 | }[target]); 302 | 303 | expect(escape('Super\b')).to.equal({ 304 | mysql: '\'Super\\b\'', 305 | pg: 'e\'Super\\b\'', 306 | }[target]); 307 | }); 308 | 309 | it('\\n gets escaped', () => { 310 | expect(escape('Sup\ner')).to.equal({ 311 | mysql: '\'Sup\\ner\'', 312 | pg: 'e\'Sup\\ner\'', 313 | }[target]); 314 | 315 | expect(escape('Super\n')).to.equal({ 316 | mysql: '\'Super\\n\'', 317 | pg: 'e\'Super\\n\'', 318 | }[target]); 319 | }); 320 | 321 | it('\\r gets escaped', () => { 322 | expect(escape('Sup\rer')).to.equal({ 323 | mysql: '\'Sup\\rer\'', 324 | pg: 'e\'Sup\\rer\'', 325 | }[target]); 326 | 327 | expect(escape('Super\r')).to.equal({ 328 | mysql: '\'Super\\r\'', 329 | pg: 'e\'Super\\r\'', 330 | }[target]); 331 | }); 332 | 333 | it('\\t gets escaped', () => { 334 | expect(escape('Sup\ter')).to.equal({ 335 | mysql: '\'Sup\\ter\'', 336 | pg: 'e\'Sup\\ter\'', 337 | }[target]); 338 | 339 | expect(escape('Super\t')).to.equal({ 340 | mysql: '\'Super\\t\'', 341 | pg: 'e\'Super\\t\'', 342 | }[target]); 343 | }); 344 | 345 | it('\\ gets escaped', () => { 346 | expect(escape('Sup\\er')).to.equal({ 347 | mysql: '\'Sup\\\\er\'', 348 | pg: 'e\'Sup\\\\er\'', 349 | }[target]); 350 | 351 | expect(escape('Super\\')).to.equal({ 352 | mysql: '\'Super\\\\\'', 353 | pg: 'e\'Super\\\\\'', 354 | }[target]); 355 | }); 356 | 357 | it('\\u001a (ascii 26 - Windows EOF) gets replaced', () => { 358 | expect(escape('Sup\u001aer')).to.equal({ 359 | mysql: '\'Sup\\Zer\'', 360 | pg: 'e\'Sup\\x1aer\'', 361 | }[target]); 362 | 363 | expect(escape('Super\u001a')).to.equal({ 364 | mysql: '\'Super\\Z\'', 365 | pg: 'e\'Super\\x1a\'', 366 | }[target]); 367 | }); 368 | 369 | it('single quotes get escaped', () => { 370 | expect(escape('Sup\'er')).to.equal({ 371 | mysql: '\'Sup\\\'er\'', 372 | pg: 'e\'Sup\'\'er\'', 373 | }[target]); 374 | 375 | expect(escape('Super\'')).to.equal({ 376 | mysql: '\'Super\\\'\'', 377 | pg: 'e\'Super\'\'\'', 378 | }[target]); 379 | }); 380 | 381 | it('double quotes get escaped', () => { 382 | expect(escape('Sup"er')).to.equal({ 383 | mysql: '\'Sup\\"er\'', 384 | pg: 'e\'Sup\\"er\'', 385 | }[target]); 386 | 387 | expect(escape('Super"')).to.equal({ 388 | mysql: '\'Super\\"\'', 389 | pg: 'e\'Super\\"\'', 390 | }[target]); 391 | }); 392 | 393 | it('dollar signs get escaped', () => { 394 | expect(escape('foo$$; DELETE')).to.equal({ 395 | mysql: String.raw`'foo\$\$; DELETE'`, 396 | pg: String.raw`e'foo\$\$; DELETE'`, 397 | }[target]); 398 | }); 399 | 400 | it('dates are converted to YYYY-MM-DD HH:II:SS.sss', () => { 401 | const expected = '2012-05-07 11:42:03.002'; 402 | const date = new Date(2012, 4, 7, 11, 42, 3, 2); 403 | const string = escape(date); 404 | 405 | expect(string).to.equal(`'${ expected }'`); 406 | }); 407 | 408 | it('dates are converted to specified time zone "Z"', () => { 409 | const expected = '2012-05-07 11:42:03.002'; 410 | const date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); 411 | const string = escape(date, false, 'Z'); 412 | 413 | expect(string).to.equal(`'${ expected }'`); 414 | }); 415 | 416 | it('dates are converted to specified time zone "+01"', () => { 417 | const expected = '2012-05-07 12:42:03.002'; 418 | const date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); 419 | const string = escape(date, false, '+01'); 420 | 421 | expect(string).to.equal(`'${ expected }'`); 422 | }); 423 | 424 | it('dates are converted to specified time zone "+0200"', () => { 425 | const expected = '2012-05-07 13:42:03.002'; 426 | const date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); 427 | const string = escape(date, false, '+0200'); 428 | 429 | expect(string).to.equal(`'${ expected }'`); 430 | }); 431 | 432 | it('dates are converted to specified time zone "-05:00"', () => { 433 | const expected = '2012-05-07 06:42:03.002'; 434 | const date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); 435 | const string = escape(date, false, '-05:00'); 436 | 437 | expect(string).to.equal(`'${ expected }'`); 438 | }); 439 | 440 | it('dates are converted to UTC for unknown time zone', () => { 441 | const date = new Date(Date.UTC(2012, 4, 7, 11, 42, 3, 2)); 442 | const expected = escape(date, false, 'Z'); 443 | const string = escape(date, false, 'foo'); 444 | 445 | expect(string).to.equal(expected); 446 | }); 447 | 448 | it('invalid dates are converted to null', () => { 449 | const date = new Date(NaN); 450 | const string = escape(date); 451 | 452 | expect(string).to.equal('NULL'); 453 | }); 454 | 455 | it('buffers are converted to hex', () => { 456 | const buffer = Buffer.from([ 0, 1, 254, 255 ]); 457 | const string = escape(buffer); 458 | 459 | expect(string).to.equal('X\'0001feff\''); 460 | }); 461 | 462 | it('buffers object cannot inject SQL', () => { 463 | const buffer = Buffer.from([ 0, 1, 254, 255 ]); 464 | buffer.toString = () => '00\' OR \'1\'=\'1'; 465 | const string = escape(buffer); 466 | 467 | expect(string).to.equal('X\'0001feff\''); 468 | }); 469 | 470 | it('NaN -> NaN', () => { 471 | expect(escape(NaN)).to.equal('NaN'); 472 | }); 473 | 474 | it('Infinity -> Infinity', () => { 475 | expect(escape(Infinity)).to.equal('Infinity'); 476 | }); 477 | }); 478 | } 479 | 480 | describe('all delimiters', () => { 481 | const { escapeDelimited } = escapers.pg; 482 | it('ok', () => { 483 | for (const okDelim of [ '\'', '"', 'u&"', 'u&\'', 'b\'', 'x\'', 'e\'', '$$', '$foo$' ]) { 484 | expect(() => escapeDelimited('x', okDelim, null, false)).to.not.throw(); 485 | } 486 | }); 487 | it('bad', () => { 488 | for (const badDelim of [ '', '?', '$x', '$$$', 'z\'' ]) { 489 | expect(() => escapeDelimited('x', badDelim, null, false)).to.throw(Error, 'Cannot escape'); 490 | } 491 | }); 492 | }); 493 | -------------------------------------------------------------------------------- /test/example-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint "id-length": 0, "id-blacklist": 0 */ 19 | 20 | 'use strict'; 21 | 22 | const { expect } = require('chai'); 23 | const { describe, it } = require('mocha'); 24 | 25 | const { mysql, pg } = require('../index.js'); 26 | 27 | describe('example code', () => { 28 | describe('README.md', () => { 29 | // These mirror example code in ../README.md so if you modify this, 30 | // be sure to reflect changes there. 31 | 32 | describe('SELECT various', () => { 33 | it('mysql', () => { 34 | const table = 'table'; 35 | const ids = [ 'x', 'y', 'z' ]; 36 | const str = 'foo\'"bar'; 37 | 38 | const query = mysql`SELECT * FROM \`${ table }\` WHERE id IN (${ ids }) AND s=${ str }`; 39 | 40 | expect(query.content).to.equal( 41 | 'SELECT * FROM `table` WHERE id IN (\'x\', \'y\', \'z\') AND s=\'foo\\\'\\"bar\''); 42 | }); 43 | it('pg', () => { 44 | const table = 'table'; 45 | const ids = [ 'x', 'y', 'z' ]; 46 | const str = 'foo\'"bar'; 47 | 48 | const query = pg`SELECT * FROM "${ table }" WHERE id IN (${ ids }) AND s=${ str }`; 49 | 50 | expect(query.content).to.equal( 51 | String.raw`SELECT * FROM "table" WHERE id IN ('x', 'y', 'z') AND s=e'foo''\"bar'`); 52 | }); 53 | }); 54 | it('UPDATE obj', () => { 55 | const column = 'users'; 56 | const userId = 1; 57 | const data = { 58 | email: 'foobar@example.com', 59 | modified: mysql`NOW()`, 60 | }; 61 | const query = mysql`UPDATE \`${ column }\` SET ${ data } WHERE \`id\` = ${ userId }`; 62 | 63 | expect(query.content).to.equal( 64 | 'UPDATE `users` SET `email` = \'foobar@example.com\', `modified` = NOW() WHERE `id` = 1'); 65 | }); 66 | it('chains', () => { 67 | const data = { a: 1 }; 68 | const whereClause = mysql`WHERE ${ data }`; 69 | expect(mysql`SELECT * FROM TABLE ${ whereClause }`.content).to.equal( 70 | 'SELECT * FROM TABLE WHERE `a` = 1'); 71 | }); 72 | it('no excess quotes', () => { 73 | expect(mysql`SELECT '${ 'foo' }' `.content).to.equal('SELECT \'foo\' '); 74 | expect(mysql`SELECT ${ 'foo' } `.content).to.equal('SELECT \'foo\' '); 75 | }); 76 | it('backtick delimited', () => { 77 | expect(mysql`SELECT \`${ 'id' }\` FROM \`TABLE\``.content).to.equal( 78 | 'SELECT `id` FROM `TABLE`'); 79 | }); 80 | it('raw escapes', () => { 81 | expect(mysql`SELECT "\n"`.content) 82 | .to.equal(String.raw`SELECT "\n"`); 83 | }); 84 | it('dates', () => { 85 | const timeZone = 'GMT'; 86 | const date = new Date(Date.UTC(2000, 0, 1)); // eslint-disable-line no-magic-numbers 87 | expect(mysql({ timeZone })`SELECT ${ date }`.content) 88 | .to.equal('SELECT \'2000-01-01 00:00:00.000\''); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/mysql-lexer-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | const { expect } = require('chai'); 21 | const { describe, it } = require('mocha'); 22 | const mysqlLexer = require('../lib/mysql-lexer.js'); 23 | 24 | function tokens(...chunks) { 25 | const { makeLexer } = mysqlLexer; 26 | const lexer = makeLexer(); 27 | const out = []; 28 | for (let i = 0, len = chunks.length; i < len; ++i) { 29 | out.push(lexer(chunks[i]) || '_'); 30 | } 31 | return out.join(','); 32 | } 33 | 34 | describe('mysql template lexer', () => { 35 | it('empty string', () => { 36 | expect(tokens('')).to.equal('_'); 37 | }); 38 | it('hash comments', () => { 39 | expect(tokens(' # "foo\n', '')).to.equal('_,_'); 40 | }); 41 | it('dash comments', () => { 42 | expect(tokens(' -- \'foo\n', '')).to.equal('_,_'); 43 | }); 44 | it('dash dash participates in number literal', () => { 45 | expect(tokens('SELECT (1--1) + "', '"')).to.equal('",_'); 46 | }); 47 | it('block comments', () => { 48 | expect(tokens(' /* `foo */', '')).to.equal('_,_'); 49 | }); 50 | it('dq', () => { 51 | expect(tokens('SELECT "foo"')).to.equal('_'); 52 | expect(tokens('SELECT `foo`, "foo"')).to.equal('_'); 53 | expect(tokens('SELECT "', '"')).to.equal('",_'); 54 | expect(tokens('SELECT "x', '"')).to.equal('",_'); 55 | expect(tokens('SELECT "\'', '"')).to.equal('",_'); 56 | expect(tokens('SELECT "`', '"')).to.equal('",_'); 57 | expect(tokens('SELECT """', '"')).to.equal('",_'); 58 | expect(tokens('SELECT "\\"', '"')).to.equal('",_'); 59 | }); 60 | it('sq', () => { 61 | expect(tokens('SELECT \'foo\'')).to.equal('_'); 62 | expect(tokens('SELECT `foo`, \'foo\'')).to.equal('_'); 63 | expect(tokens('SELECT \'', '\'')).to.equal('\',_'); 64 | expect(tokens('SELECT \'x', '\'')).to.equal('\',_'); 65 | expect(tokens('SELECT \'"', '\'')).to.equal('\',_'); 66 | expect(tokens('SELECT \'`', '\'')).to.equal('\',_'); 67 | expect(tokens('SELECT \'\'\'', '\'')).to.equal('\',_'); 68 | expect(tokens('SELECT \'\\\'', '\'')).to.equal('\',_'); 69 | }); 70 | it('bq', () => { 71 | expect(tokens('SELECT `foo`')).to.equal('_'); 72 | expect(tokens('SELECT "foo", `foo`')).to.equal('_'); 73 | expect(tokens('SELECT `', '`')).to.equal('`,_'); 74 | expect(tokens('SELECT `x', '`')).to.equal('`,_'); 75 | expect(tokens('SELECT `\'', '`')).to.equal('`,_'); 76 | expect(tokens('SELECT `"', '`')).to.equal('`,_'); 77 | expect(tokens('SELECT ```', '`')).to.equal('`,_'); 78 | expect(tokens('SELECT `\\`', '`')).to.equal('`,_'); 79 | }); 80 | it('replay error', () => { 81 | const lexer = mysqlLexer.makeLexer(); 82 | expect(lexer('SELECT ')).to.equal(null); 83 | expect(() => lexer(' # ')).to.throw( 84 | Error, null, 'Expected delimiter at " # "'); 85 | // Providing more input throws the same error. 86 | expect(() => lexer(' ')).to.throw( 87 | Error, null, 'Expected delimiter at " # "'); 88 | }); 89 | it('unfinished escape squence', () => { 90 | const lexer = mysqlLexer.makeLexer(); 91 | expect(() => lexer('SELECT "\\')).to.throw( 92 | Error, null, 'Expected "\\\\" at "\\\\"'); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/mysql-tag-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint no-magic-numbers: 0 */ 19 | 20 | 'use strict'; 21 | 22 | require('module-keys/cjs').polyfill(module, require); 23 | 24 | const { expect } = require('chai'); 25 | const { describe, it } = require('mocha'); 26 | const { mysql, SqlFragment, SqlId } = require('../index.js'); 27 | 28 | const { Mintable } = require('node-sec-patterns'); 29 | 30 | const isSqlFragment = Mintable.verifierFor(SqlFragment); 31 | 32 | function unwrapMinterFor(MintableType) { 33 | return require.moduleKeys.unbox( 34 | Mintable.minterFor(MintableType), 35 | () => true, 36 | () => { 37 | throw new Error('Cannot mint'); 38 | }); 39 | } 40 | const mintSqlFragment = unwrapMinterFor(SqlFragment); 41 | const mintSqlId = unwrapMinterFor(SqlId); 42 | 43 | function runTagTest(golden, test) { 44 | // Run multiply to test memoization bugs. 45 | for (let i = 3; --i >= 0;) { 46 | let result = test(); 47 | if (result && isSqlFragment(result)) { 48 | result = result.content; 49 | } else { 50 | throw new Error(`Expected raw not ${ result }`); 51 | } 52 | expect(result).to.equal(golden); 53 | } 54 | } 55 | 56 | describe('mysql template tag', () => { 57 | it('numbers', () => { 58 | runTagTest( 59 | 'SELECT 2', 60 | () => mysql`SELECT ${ 1 + 1 }`); 61 | }); 62 | it('date', () => { 63 | const date = new Date(Date.UTC(2000, 0, 1, 0, 0, 0)); 64 | runTagTest( 65 | 'SELECT \'2000-01-01 00:00:00.000\'', 66 | () => mysql({ timeZone: 'GMT' })`SELECT ${ date }`); 67 | }); 68 | it('string', () => { 69 | runTagTest( 70 | 'SELECT \'Hello, World!\\n\'', 71 | () => mysql`SELECT ${ 'Hello, World!\n' }`); 72 | }); 73 | it('stringify', () => { 74 | const obj = { 75 | Hello: 'World!', 76 | toString() { 77 | return 'Hello, World!'; 78 | }, 79 | }; 80 | runTagTest( 81 | 'SELECT \'Hello, World!\'', 82 | () => mysql({ stringifyObjects: true })`SELECT ${ obj }`); 83 | runTagTest( 84 | 'SELECT * FROM t WHERE `Hello` = \'World!\'', 85 | () => mysql({ stringifyObjects: false })`SELECT * FROM t WHERE ${ obj }`); 86 | }); 87 | it('identifier', () => { 88 | runTagTest( 89 | 'SELECT `foo`', 90 | () => mysql`SELECT ${ mintSqlId('foo') }`); 91 | }); 92 | it('blob', () => { 93 | runTagTest( 94 | 'SELECT "\x1f8p\xbe\\\'OlI\xb3\xe3\\Z\x0cg(\x95\x7f"', 95 | () => 96 | mysql`SELECT "${ Buffer.from('1f3870be274f6c49b3e31a0c6728957f', 'hex') }"` 97 | ); 98 | }); 99 | it('null', () => { 100 | runTagTest( 101 | 'SELECT NULL', 102 | () => 103 | mysql`SELECT ${ null }` 104 | ); 105 | }); 106 | it('undefined', () => { 107 | runTagTest( 108 | 'SELECT NULL', 109 | () => 110 | mysql`SELECT ${ undefined }` // eslint-disable-line no-undefined 111 | ); 112 | }); 113 | it('negative zero', () => { 114 | runTagTest( 115 | 'SELECT (1 / 0)', 116 | () => 117 | mysql`SELECT (1 / ${ -0 })` 118 | ); 119 | }); 120 | it('raw', () => { 121 | const raw = mintSqlFragment('1 + 1'); 122 | runTagTest( 123 | 'SELECT 1 + 1', 124 | () => mysql`SELECT ${ raw }`); 125 | }); 126 | it('string in dq string', () => { 127 | runTagTest( 128 | 'SELECT "Hello, World!\\n"', 129 | () => mysql`SELECT "Hello, ${ 'World!' }\n"`); 130 | }); 131 | it('string in sq string', () => { 132 | runTagTest( 133 | 'SELECT \'Hello, World!\\n\'', 134 | () => mysql`SELECT 'Hello, ${ 'World!' }\n'`); 135 | }); 136 | it('string after string in string', () => { 137 | // The following tests check obliquely that '?' is not 138 | // interpreted as a prepared statement meta-character 139 | // internally. 140 | runTagTest( 141 | 'SELECT \'Hello\', "World?"', 142 | () => mysql`SELECT '${ 'Hello' }', "World?"`); 143 | }); 144 | it('string before string in string', () => { 145 | runTagTest( 146 | 'SELECT \'Hello?\', \'World?\'', 147 | () => mysql`SELECT 'Hello?', '${ 'World?' }'`); 148 | }); 149 | it('number after string in string', () => { 150 | runTagTest( 151 | 'SELECT \'Hello?\', 123', 152 | () => mysql`SELECT '${ 'Hello?' }', ${ 123 }`); 153 | }); 154 | it('number before string in string', () => { 155 | runTagTest( 156 | 'SELECT 123, \'World?\'', 157 | () => mysql`SELECT ${ 123 }, '${ 'World?' }'`); 158 | }); 159 | it('string in identifier', () => { 160 | runTagTest( 161 | 'SELECT `foo`', 162 | () => mysql`SELECT \`${ 'foo' }\``); 163 | }); 164 | it('identifier in identifier', () => { 165 | runTagTest( 166 | 'SELECT `foo`', 167 | () => mysql`SELECT \`${ mintSqlId('foo') }\``); 168 | }); 169 | it('plain quoted identifier', () => { 170 | runTagTest( 171 | 'SELECT `ID`', 172 | () => mysql`SELECT \`ID\``); 173 | }); 174 | it('backquotes in identifier', () => { 175 | runTagTest( 176 | 'SELECT `\\\\`', 177 | () => mysql`SELECT \`\\\``); 178 | const strings = [ 'SELECT `\\\\`' ]; 179 | strings.raw = strings.slice(); 180 | runTagTest('SELECT `\\\\`', () => mysql(strings)); 181 | }); 182 | it('backquotes in strings', () => { 183 | runTagTest( 184 | 'SELECT "`\\\\", \'`\\\\\'', 185 | () => mysql`SELECT "\`\\", '\`\\'`); 186 | }); 187 | it('number in identifier', () => { 188 | runTagTest( 189 | 'SELECT `foo_123`', 190 | () => mysql`SELECT \`foo_${ 123 }\``); 191 | }); 192 | it('array', () => { 193 | const id = mintSqlId('foo'); 194 | const frag = mintSqlFragment('1 + 1'); 195 | const values = [ 123, 'foo', id, frag ]; 196 | runTagTest( 197 | 'SELECT X FROM T WHERE X IN (123, \'foo\', `foo`, 1 + 1)', 198 | () => mysql`SELECT X FROM T WHERE X IN (${ values })`); 199 | }); 200 | it('unclosed-sq', () => { 201 | expect(() => mysql`SELECT '${ 'foo' }`).to.throw(); 202 | }); 203 | it('unclosed-dq', () => { 204 | expect(() => mysql`SELECT "foo`).to.throw(); 205 | }); 206 | it('unclosed-bq', () => { 207 | expect(() => mysql`SELECT \`${ 'foo' }`).to.throw(); 208 | }); 209 | it('unclosed-comment', () => { 210 | // Ending in a comment is a concatenation hazard. 211 | // See comments in lib/es6/Lexer.js. 212 | expect(() => mysql`SELECT (${ 0 }) -- comment`).to.throw(); 213 | }); 214 | it('merge-word-string', () => { 215 | runTagTest( 216 | 'SELECT utf8\'foo\'', 217 | () => mysql`SELECT utf8${ 'foo' }`); 218 | }); 219 | it('merge-string-string', () => { 220 | runTagTest( 221 | // Adjacent string tokens are concatenated, but 'a''b' is a 222 | // 3-char string with a single-quote in the middle. 223 | 'SELECT \'a\' \'b\'', 224 | () => mysql`SELECT ${ 'a' }${ 'b' }`); 225 | }); 226 | it('merge-bq-bq', () => { 227 | runTagTest( 228 | 'SELECT `a` `b`', 229 | () => mysql`SELECT ${ mintSqlId('a') }${ mintSqlId('b') }`); 230 | }); 231 | it('merge-static-string-string', () => { 232 | runTagTest( 233 | 'SELECT \'a\' \'b\'', 234 | () => mysql`SELECT 'a'${ 'b' }`); 235 | }); 236 | it('merge-string-static-string', () => { 237 | runTagTest( 238 | 'SELECT \'a\' \'b\'', 239 | () => mysql`SELECT ${ 'a' }'b'`); 240 | }); 241 | it('not-a-merge-hazard', () => { 242 | runTagTest( 243 | 'SELECT \'a\'\'b\'', 244 | () => mysql`SELECT 'a''b'`); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /test/pg-lexer-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | 'use strict'; 19 | 20 | const { expect } = require('chai'); 21 | const { describe, it } = require('mocha'); 22 | const pgLexer = require('../lib/pg-lexer.js'); 23 | 24 | function tokens(...chunks) { 25 | const { makeLexer } = pgLexer; 26 | const lexer = makeLexer(); 27 | const out = []; 28 | for (let i = 0, len = chunks.length; i < len; ++i) { 29 | out.push(lexer(chunks[i]) || '_'); 30 | } 31 | return out.join(','); 32 | } 33 | 34 | describe('pg template lexer', () => { 35 | it('empty string', () => { 36 | expect(tokens('')).to.equal('_'); 37 | }); 38 | it('hash comments', () => { 39 | // Unlike MySQL, postgres does not recognize # comments. 40 | expect(tokens(' # "foo\n', '')).to.equal('","'); 41 | }); 42 | it('dash comments', () => { 43 | expect(tokens(' -- \'foo\n', '')).to.equal('_,_'); 44 | }); 45 | it('dash dash in number literal', () => { 46 | // www.postgresql.org/docs/9.5/static/sql-syntax-lexical.html says 47 | // "-- and /* cannot appear anywhere in an operator name, since 48 | // they will be taken as the start of a comment." 49 | // so it looks like there is no rule similar to MySQL where "--" 50 | // when used as a comment delimiter has to not be immediately 51 | // preceded and followed by numeric or identifier characters. 52 | expect(() => tokens('SELECT (1--1)')) 53 | .to.throw(Error, 'Unterminated line comment: --1)'); 54 | }); 55 | it('block comments', () => { 56 | expect(tokens(' /* `foo */', '')).to.equal('_,_'); 57 | expect(() => tokens(' /* `foo ')) 58 | .to.throw(Error, 'Unterminated block comment: /* `foo'); 59 | expect(tokens(' /* /* foo */ \' */', '')).to.equal('_,_'); 60 | }); 61 | it('dq', () => { 62 | expect(tokens('SELECT "foo"')).to.equal('_'); 63 | expect(tokens('SELECT `foo`, "foo"')).to.equal('_'); 64 | expect(tokens('SELECT "', '"')).to.equal('",_'); 65 | expect(tokens('SELECT "x', '"')).to.equal('",_'); 66 | expect(tokens('SELECT "\'', '"')).to.equal('",_'); 67 | expect(tokens('SELECT "`', '"')).to.equal('",_'); 68 | expect(tokens('SELECT """', '"')).to.equal('",_'); 69 | // C-style escape sequences not supported in double 70 | // quoted strings unless U& 71 | expect(tokens('SELECT "\\"', '"')).to.equal('_,"'); 72 | }); 73 | it('U&dq', () => { 74 | expect(tokens('SELECT U&"foo"')).to.equal('_'); 75 | expect(tokens('SELECT `foo`, U&"foo"')).to.equal('_'); 76 | expect(tokens('SELECT U&"', '"')).to.equal('u&",_'); 77 | expect(tokens('SELECT U&"x', '"')).to.equal('u&",_'); 78 | expect(tokens('SELECT U&"\'', '"')).to.equal('u&",_'); 79 | expect(tokens('SELECT U&"`', '"')).to.equal('u&",_'); 80 | expect(tokens('SELECT U&"""', '"')).to.equal('u&",_'); 81 | // C-style escape sequences not supported in double 82 | // quoted strings unless U& 83 | expect(tokens('SELECT U&"\\"', '"')).to.equal('u&",_'); 84 | expect(() => tokens('SELECT U&"\\')).to.throw(); 85 | }); 86 | it('sq', () => { 87 | expect(tokens('SELECT \'foo\'')).to.equal('_'); 88 | expect(tokens('SELECT `foo`, \'foo\'')).to.equal('_'); 89 | expect(tokens('SELECT \'', '\'')).to.equal('\',_'); 90 | expect(tokens('SELECT \'x', '\'')).to.equal('\',_'); 91 | expect(tokens('SELECT \'"', '\'')).to.equal('\',_'); 92 | expect(tokens('SELECT \'`', '\'')).to.equal('\',_'); 93 | expect(tokens('SELECT \'\'\'', '\'')).to.equal('\',_'); 94 | expect(tokens('SELECT \'\\\'', '\'')).to.equal('_,\''); 95 | }); 96 | it('Esq', () => { 97 | expect(tokens('SELECT E\'foo\'')).to.equal('e'); 98 | expect(tokens('SELECT E\'foo\';')).to.equal('_'); 99 | expect(tokens('SELECT E\'foo')).to.equal('e\''); 100 | expect(tokens('SELECT `foo`, E\'foo\';')).to.equal('_'); 101 | expect(tokens('SELECT E\'', '\';')).to.equal('e\',_'); 102 | expect(tokens('SELECT E\'x', '\';')).to.equal('e\',_'); 103 | expect(tokens('SELECT E\'"', '\';')).to.equal('e\',_'); 104 | expect(tokens('SELECT E\'`', '\';')).to.equal('e\',_'); 105 | expect(tokens('SELECT E\'\'\'', '\';')).to.equal('e\',_'); 106 | expect(tokens('SELECT E\'\\\'', '\';')).to.equal('e\',_'); 107 | expect(tokens('SELECT e\'\\\'', '\';')).to.equal('e\',_'); 108 | // e' applies to subsequent single quoted strings. 109 | // 4.1.2.2. String Constants with C-style Escapes 110 | // (When continuing an escape string constant across lines, 111 | // write E only before the first opening quote.) 112 | expect(tokens('SELECT e\'foo\'\n \'\\\'')).to.equal('e\''); 113 | expect(tokens('SELECT e\'foo\' -- \n \'\\\'')).to.equal('e\''); 114 | expect(tokens('SELECT E\'foo\' /* */ \n \'\\\'')).to.equal('e\''); 115 | expect(tokens('SELECT e\'foo\' /* /**/\n*/ \'\\\'')).to.equal('e\''); 116 | // Check that we can look through interpolations for e'' continuations. 117 | expect(tokens('SELECT e\'foo', 'bar\' /* */ \n ', '\n ', ';')).to.equal('e\',e,e,_'); 118 | }); 119 | it('U&sq', () => { 120 | expect(tokens('SELECT U&\'foo\'')).to.equal('_'); 121 | expect(tokens('SELECT `foo`, U&\'foo\'')).to.equal('_'); 122 | expect(tokens('SELECT U&\'', '\'')).to.equal('u&\',_'); 123 | expect(tokens('SELECT U&\'x', '\'')).to.equal('u&\',_'); 124 | expect(tokens('SELECT U&\'"', '\'')).to.equal('u&\',_'); 125 | expect(tokens('SELECT U&\'`', '\'')).to.equal('u&\',_'); 126 | expect(tokens('SELECT U&\'\'\'', '\'')).to.equal('u&\',_'); 127 | expect(tokens('SELECT U&\'\\\'', '\'')).to.equal('u&\',_'); 128 | expect(tokens('SELECT u&\'\\\'', '\'')).to.equal('u&\',_'); 129 | }); 130 | it('$$', () => { 131 | expect(tokens('SELECT $$foo$$')).to.equal('_'); 132 | expect(tokens('SELECT $$foo')).to.equal('$$'); 133 | expect(tokens('SELECT $$foo', '$$')).to.equal('$$,_'); 134 | expect(tokens('SELECT $$foo', 'bar$$')).to.equal('$$,_'); 135 | expect(tokens('SELECT $$foo\\', 'bar$$')).to.equal('$$,_'); 136 | expect(tokens('SELECT $foo$')).to.equal('$foo$'); 137 | expect(tokens('SELECT $foo$bar')).to.equal('$foo$'); 138 | expect(tokens('SELECT $foo$bar$foo$')).to.equal('_'); 139 | expect(tokens('SELECT $$foo\\$$')).to.equal('_'); 140 | expect(tokens('SELECT $foo$bar$baz$ ')).to.equal('$foo$'); 141 | 142 | expect(() => tokens('SELECT $foo$ $')) 143 | .to.throw(Error, 'merge hazard \'$\' at end of $foo$ delimited string'); 144 | expect(() => tokens('SELECT $foo$ $f')) 145 | .to.throw(Error, 'merge hazard \'$f\' at end of $foo$ delimited string'); 146 | expect(() => tokens('SELECT $foo$ $fo')) 147 | .to.throw(Error, 'merge hazard \'$fo\' at end of $foo$ delimited string'); 148 | expect(() => tokens('SELECT $foo$ $foo')) 149 | .to.throw(Error, 'merge hazard \'$foo\' at end of $foo$ delimited string'); 150 | expect(() => tokens('SELECT $foo$ $x$')) 151 | .to.throw(Error, 'merge hazard \'$\' at end of $foo$ delimited string'); 152 | expect(tokens('SELECT $foo$ $foo$')).to.equal('_'); 153 | expect(tokens('SELECT $foo$ $x')).to.equal('$foo$'); 154 | }); 155 | it('replay error', () => { 156 | const lexer = pgLexer.makeLexer(); 157 | expect(lexer('SELECT ')).to.equal(null); 158 | expect(() => lexer(' -- ')).to.throw( 159 | Error, 'Unterminated line comment: -- '); 160 | // Providing more input throws the same error. 161 | expect(() => lexer(' ')).to.throw( 162 | Error, 'Unterminated line comment: -- '); 163 | }); 164 | it('unfinished escape squence', () => { 165 | const lexer = pgLexer.makeLexer(); 166 | expect(() => lexer('SELECT E\'\\')).to.throw( 167 | Error, 'Incomplete escape sequence in e\' delimited string at `\\`'); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/pg-tag-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* eslint no-magic-numbers: 0 */ 19 | 20 | 'use strict'; 21 | 22 | require('module-keys/cjs').polyfill(module, require); 23 | 24 | const { expect } = require('chai'); 25 | const { describe, it } = require('mocha'); 26 | const { pg, SqlFragment, SqlId } = require('../index.js'); 27 | 28 | const { Mintable } = require('node-sec-patterns'); 29 | 30 | const isSqlFragment = Mintable.verifierFor(SqlFragment); 31 | 32 | function unwrapMinterFor(MintableType) { 33 | return require.moduleKeys.unbox( 34 | Mintable.minterFor(MintableType), 35 | () => true, 36 | () => { 37 | throw new Error('Cannot mint'); 38 | }); 39 | } 40 | const mintSqlFragment = unwrapMinterFor(SqlFragment); 41 | const mintSqlId = unwrapMinterFor(SqlId); 42 | 43 | function runTagTest(golden, test) { 44 | // Run multiply to test memoization bugs. 45 | for (let i = 3; --i >= 0;) { 46 | let result = test(); 47 | if (result && isSqlFragment(result)) { 48 | result = result.content; 49 | } else { 50 | throw new Error(`Expected raw not ${ result }`); 51 | } 52 | expect(result).to.equal(golden); 53 | } 54 | } 55 | 56 | describe('pg template tag', () => { 57 | it('numbers', () => { 58 | runTagTest( 59 | 'SELECT 2', 60 | () => pg`SELECT ${ 1 + 1 }`); 61 | }); 62 | it('date', () => { 63 | const date = new Date(Date.UTC(2000, 0, 1, 0, 0, 0)); 64 | runTagTest( 65 | 'SELECT \'2000-01-01 00:00:00.000\'', 66 | () => pg({ timeZone: 'GMT' })`SELECT ${ date }`); 67 | }); 68 | it('string', () => { 69 | runTagTest( 70 | 'SELECT e\'Hello, World!\\n\'', 71 | () => pg`SELECT ${ 'Hello, World!\n' }`); 72 | }); 73 | it('stringify', () => { 74 | const obj = { 75 | Hello: 'World!', 76 | toString() { 77 | return 'Hello, World!'; 78 | }, 79 | }; 80 | runTagTest( 81 | 'SELECT \'Hello, World!\'', 82 | () => pg({ stringifyObjects: true })`SELECT ${ obj }`); 83 | runTagTest( 84 | 'SELECT * FROM t WHERE "Hello" = \'World!\'', 85 | () => pg({ stringifyObjects: false })`SELECT * FROM t WHERE ${ obj }`); 86 | }); 87 | describe('identifier', () => { 88 | const str = 'O\'Reilly the "Unescaped"'; 89 | const id = mintSqlId(str); 90 | it('bare id', () => { 91 | runTagTest('SELECT "O\'Reilly the ""Unescaped"""', () => pg`SELECT ${ id }`); 92 | }); 93 | it('dq str', () => { 94 | runTagTest('SELECT "O\'Reilly the ""Unescaped"""', () => pg`SELECT "${ str }"`); 95 | }); 96 | it('dq id', () => { 97 | runTagTest('SELECT "O\'Reilly the ""Unescaped"""', () => pg`SELECT "${ id }"`); 98 | }); 99 | it('U&dq str', () => { 100 | runTagTest('SELECT U&"O\\0027Reilly the \\0022Unescaped\\0022"', () => pg`SELECT U&"${ str }"`); 101 | }); 102 | it('U&dq id', () => { 103 | runTagTest('SELECT U&"O\\0027Reilly the \\0022Unescaped\\0022"', () => pg`SELECT U&"${ id }"`); 104 | }); 105 | }); 106 | describe('blob', () => { 107 | const blob = Buffer.from('1f3870be274f6c49b3e31a0c6728957f', 'hex'); 108 | it('x', () => { 109 | runTagTest( 110 | 'SELECT x\'1f3870be274f6c49b3e31a0c6728957f\'', 111 | () => pg`SELECT x'${ blob }'` 112 | ); 113 | }); 114 | it('b', () => { 115 | runTagTest( 116 | 'SELECT b\'000111110011100001110000101111100010011101001111011011000100' + 117 | '10011011001111100011000110100000110001100111001010001001010101111111\'', 118 | () => pg`SELECT b'${ blob }'` 119 | ); 120 | }); 121 | it('e', () => { 122 | runTagTest( 123 | 'SELECT e\'\x1f8p\xbe\'\'OlI\xb3\xe3\\x1a\x0cg(\x95\x7f\'', 124 | () => pg`SELECT e'${ blob }'` 125 | ); 126 | }); 127 | it('u&', () => { 128 | runTagTest( 129 | 'SELECT u&\'\x1f8p\xbe\\0027OlI\xb3\xe3\\001a\x0cg(\x95\x7f\'', 130 | () => pg`SELECT u&'${ blob }'` 131 | ); 132 | }); 133 | it('raw', () => { 134 | runTagTest( 135 | 'SELECT \'\x1f8p\xbe\'\'OlI\xb3\xe3\x1a\x0cg(\x95\x7f\'', 136 | () => pg`SELECT '${ blob }'` 137 | ); 138 | }); 139 | it('$$', () => { 140 | runTagTest( 141 | 'SELECT $$\x1f8p\xbe\'OlI\xb3\xe3\x1a\x0cg(\x95\x7f$$', 142 | () => pg`SELECT $$${ blob }$$` 143 | ); 144 | }); 145 | }); 146 | it('null', () => { 147 | runTagTest( 148 | 'SELECT NULL', 149 | () => 150 | pg`SELECT ${ null }` 151 | ); 152 | }); 153 | it('undefined', () => { 154 | runTagTest( 155 | 'SELECT NULL', 156 | () => 157 | pg`SELECT ${ undefined }` // eslint-disable-line no-undefined 158 | ); 159 | }); 160 | it('negative zero', () => { 161 | runTagTest( 162 | 'SELECT (1 / 0)', 163 | () => 164 | pg`SELECT (1 / ${ -0 })` 165 | ); 166 | }); 167 | it('raw', () => { 168 | const raw = mintSqlFragment('1 + 1'); 169 | runTagTest( 170 | 'SELECT 1 + 1', 171 | () => pg`SELECT ${ raw }`); 172 | }); 173 | it('string in dq string', () => { 174 | runTagTest( 175 | 'SELECT "Hello, World!\\n"', 176 | () => pg`SELECT "Hello, ${ 'World!' }\n"`); 177 | }); 178 | it('string in sq string', () => { 179 | runTagTest( 180 | 'SELECT \'Hello, World!\\n\'', 181 | () => pg`SELECT 'Hello, ${ 'World!' }\n'`); 182 | }); 183 | it('string after string in string', () => { 184 | // The following tests check obliquely that '?' is not 185 | // interpreted as a prepared statement meta-character 186 | // internally. 187 | runTagTest( 188 | 'SELECT \'Hello\', "World?"', 189 | () => pg`SELECT '${ 'Hello' }', "World?"`); 190 | }); 191 | it('string before string in string', () => { 192 | runTagTest( 193 | 'SELECT \'Hello?\', \'World?\'', 194 | () => pg`SELECT 'Hello?', '${ 'World?' }'`); 195 | }); 196 | it('number after string in string', () => { 197 | runTagTest( 198 | 'SELECT \'Hello?\', 123', 199 | () => pg`SELECT '${ 'Hello?' }', ${ 123 }`); 200 | }); 201 | it('number before string in string', () => { 202 | runTagTest( 203 | 'SELECT 123, \'World?\'', 204 | () => pg`SELECT ${ 123 }, '${ 'World?' }'`); 205 | }); 206 | it('string in identifier', () => { 207 | runTagTest( 208 | 'SELECT "foo"', 209 | () => pg`SELECT "${ 'foo' }"`); 210 | }); 211 | it('identifier in identifier', () => { 212 | runTagTest( 213 | 'SELECT "foo"', 214 | () => pg`SELECT "${ mintSqlId('foo') }"`); 215 | }); 216 | it('plain quoted identifier', () => { 217 | runTagTest( 218 | 'SELECT "ID"', 219 | () => pg`SELECT "ID"`); 220 | }); 221 | it('dqs in identifier', () => { 222 | runTagTest( 223 | 'SELECT "\\\\"', 224 | () => pg`SELECT "\\"`); 225 | const strings = [ 'SELECT "\\\\"' ]; 226 | strings.raw = strings.slice(); 227 | runTagTest('SELECT "\\\\"', () => pg(strings)); 228 | }); 229 | it('backquotes in strings', () => { 230 | runTagTest( 231 | 'SELECT "\\`\\\\", \'\\`\\\\\'', 232 | () => pg`SELECT "\`\\", '\`\\'`); 233 | }); 234 | it('number in identifier', () => { 235 | runTagTest( 236 | 'SELECT "foo_123"', 237 | () => pg`SELECT "foo_${ 123 }"`); 238 | }); 239 | it('array', () => { 240 | const id = mintSqlId('foo'); 241 | const frag = mintSqlFragment('1 + 1'); 242 | const values = [ 123, 'foo', id, frag ]; 243 | runTagTest( 244 | 'SELECT X FROM T WHERE X IN (123, \'foo\', "foo", 1 + 1)', 245 | () => pg`SELECT X FROM T WHERE X IN (${ values })`); 246 | }); 247 | it('unclosed-sq', () => { 248 | expect(() => pg`SELECT '${ 'foo' }`).to.throw(); 249 | }); 250 | it('unclosed-dq', () => { 251 | expect(() => pg`SELECT "foo`).to.throw(); 252 | }); 253 | it('unclosed-dq-interp', () => { 254 | expect(() => pg`SELECT "${ 'foo' }`).to.throw(); 255 | }); 256 | it('unclosed-comment', () => { 257 | // Ending in a comment is a concatenation hazard. 258 | // See comments in lib/es6/Lexer.js. 259 | expect(() => pg`SELECT (${ 0 }) -- comment`).to.throw(); 260 | }); 261 | it('merge-word-string', () => { 262 | runTagTest( 263 | 'SELECT utf8\'foo\'', 264 | () => pg`SELECT utf8${ 'foo' }`); 265 | }); 266 | it('merge-string-string', () => { 267 | runTagTest( 268 | // Adjacent string tokens are concatenated, but 'a''b' is a 269 | // 3-char string with a single-quote in the middle. 270 | 'SELECT \'a\' \'b\'', 271 | () => pg`SELECT ${ 'a' }${ 'b' }`); 272 | }); 273 | it('merge-id-id', () => { 274 | runTagTest( 275 | 'SELECT "a" "b"', 276 | () => pg`SELECT ${ mintSqlId('a') }${ mintSqlId('b') }`); 277 | }); 278 | it('merge-static-string-string', () => { 279 | runTagTest( 280 | 'SELECT \'a\' \'b\'', 281 | () => pg`SELECT 'a'${ 'b' }`); 282 | }); 283 | it('merge-string-static-string', () => { 284 | runTagTest( 285 | 'SELECT \'a\' \'b\'', 286 | () => pg`SELECT ${ 'a' }'b'`); 287 | }); 288 | it('not-a-merge-hazard', () => { 289 | runTagTest( 290 | 'SELECT \'a\'\'b\'', 291 | () => pg`SELECT 'a''b'`); 292 | }); 293 | describe('literal-string-corner-cases', () => { 294 | it('$$', () => { 295 | runTagTest( 296 | 'SELECT $$x$$', 297 | () => pg`SELECT $$${ 'x' }$$`); 298 | }); 299 | it('$$ hazard', () => { 300 | expect(() => pg`SELECT $$${ '$$' }$$`).to.throw(Error, 'Cannot embed '); 301 | expect(() => pg`SELECT $$${ 'x$$x' }$$`).to.throw(Error, 'Cannot embed '); 302 | expect(() => pg`SELECT $$${ 'x$' }$$`).to.throw(Error, 'Cannot embed '); 303 | }); 304 | it('$foo$', () => { 305 | runTagTest( 306 | 'SELECT $foo$x$foo$', 307 | () => pg`SELECT $foo$${ 'x' }$foo$`); 308 | runTagTest( 309 | 'SELECT $foo$x$foox$foo$', 310 | () => pg`SELECT $foo$${ 'x$foox' }$foo$`); 311 | runTagTest( 312 | 'SELECT $foo$x$bar$x$foo$', 313 | () => pg`SELECT $foo$${ 'x$bar$x' }$foo$`); 314 | runTagTest( 315 | 'SELECT $foo$$bar$x$foo$', 316 | () => pg`SELECT $foo$${ '$bar$x' }$foo$`); 317 | runTagTest( 318 | 'SELECT $foo$$$x$foo$', 319 | () => pg`SELECT $foo$${ '$$x' }$foo$`); 320 | }); 321 | it('$foo$ hazard', () => { 322 | expect(() => pg`SELECT $foo$${ '$foo$' }$foo$`).to.throw(Error, 'Cannot embed '); 323 | expect(() => pg`SELECT $foo$${ 'x$foo$x' }$foo$`).to.throw(Error, 'Cannot embed '); 324 | expect(() => pg`SELECT $foo$${ 'x$fo' }$foo$`).to.throw(Error, 'Cannot embed '); 325 | 326 | expect(() => pg`SELECT $foo$${ '$fOo$x' }$foo$`).to.not.throw(); 327 | }); 328 | it('mixed case hazard', () => { 329 | // OK 330 | runTagTest( 331 | 'SELECT $foo$ $foo$, e\'\\$foo\\$\', "$foo$--"\n', 332 | () => pg`SELECT $foo$ $foo$, ${ '$foo$' }, "$foo$--" 333 | `); 334 | // Mixed case matters 335 | expect(() => pg`SELECT $foo$ $Foo$, ${ '$foo$' } "$foo$--" 336 | `) 337 | .to.throw(Error, 'Cannot embed '); 338 | }); 339 | }); 340 | describe('continued-strings', () => { 341 | const line = [ 'haven\'t', 'have too', 'have not! n\'t!' ]; 342 | it('e', () => { 343 | runTagTest( 344 | 'SELECT e\'\' \'haven\'\'t\'\n \'have too\'\n \'have not! n\'\'t!\'', 345 | () => 346 | pg`SELECT e'' ${ line[0] } 347 | ${ line[1] } 348 | ${ line[2] }`); 349 | }); 350 | it('ambiguity', () => { 351 | expect( 352 | () => 353 | pg`SELECT ${ line[0] } 354 | ${ line[1] } 355 | ${ line[2] }`) 356 | .to.throw(Error, 'Potential for ambiguous string continuation'); 357 | }); 358 | }); 359 | describe('meta-chars', () => { 360 | const cases = [ 361 | { 362 | metachar: '\'', 363 | want: String.raw`SELECT '''' AS "'", u&'${ '\\' }0027' AS u&"${ '\\' }0027", e''''`, 364 | }, 365 | { 366 | metachar: '"', 367 | want: String.raw`SELECT '"' AS """", u&'${ '\\' }0022' AS u&"${ '\\' }0022", e'\"'`, 368 | }, 369 | { 370 | metachar: '\0', 371 | want: String.raw`SELECT '' AS "", u&'' AS u&"", e''`, 372 | }, 373 | { 374 | metachar: '\\', 375 | want: String.raw`SELECT '\' AS "\", u&'${ '\\' }005c' AS u&"${ '\\' }005c", e'\\'`, 376 | }, 377 | { 378 | metachar: '\n', 379 | want: String.raw`SELECT '${ '\n' }' AS "${ '\n' }", u&'${ '\\' }000a' AS u&"${ '\\' }000a", e'\n'`, 380 | }, 381 | { 382 | metachar: '', 383 | want: String.raw`SELECT '' AS "", u&'' AS u&"", e''`, 384 | }, 385 | ]; 386 | for (const { metachar, want } of cases) { 387 | it(`Escaping of ${ JSON.stringify(metachar) }`, () => { 388 | const got = 389 | pg`SELECT '${ metachar }' AS "${ metachar }", u&'${ metachar }' AS u&"${ metachar }", e'${ metachar }'`; 390 | expect(got.content).to.equal(want, metachar); 391 | // TODO: maybe try to actually issue queries and check the results. 392 | // 15 Nov 2019 - manually checked the wanted SQL against psql (PostgreSQL) 10.5 393 | // conencted to a server with the default configuration produced by initdb. 394 | }); 395 | } 396 | }); 397 | }); 398 | --------------------------------------------------------------------------------