Skip to content

Commit b499473

Browse files
committed
feat: support nested sql templates
1 parent b9fd097 commit b499473

File tree

4 files changed

+130
-24
lines changed

4 files changed

+130
-24
lines changed

README.md

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,19 @@ names, attribute names, and puts other expressions into bind parameters
1111
Using the table and attribute names from your Sequelize `Model`s is much more
1212
refactor-proof in raw queries than embedding raw identifiers.
1313

14-
## Installation
14+
# Installation
1515

1616
```sh
1717
npm install --save @jcoreio/sequelize-sql-tag
1818
```
1919

20-
## Compatibility
20+
# Compatibility
2121

2222
Requires `sequelize@^4.0.0`. Once v5 is released I'll check if it's still
2323
compatible. Not making any effort to support versions < 4, but you're welcome
2424
to make a PR.
2525

26-
## Examples
26+
# Examples
2727

2828
```js
2929
const Sequelize = require('sequelize')
@@ -41,7 +41,7 @@ const lock = true
4141
sequelize.query(...sql`SELECT ${User.attributes.name} FROM ${User}
4242
WHERE ${User.attributes.birthday} = ${new Date('2346-7-11')} AND
4343
${User.attributes.active} = ${true}
44-
${Sequelize.literal(lock ? 'FOR UPDATE' : '')}`).then(console.log);
44+
${lock ? sql`FOR UPDATE` : sql``}then(console.log);
4545
// => [ [ { name: 'Jimbob' } ], Statement { sql: 'SELECT "name" FROM "Users" WHERE "birthday" = $1 AND "active" = $2 FOR UPDATE' } ]
4646
```
4747

@@ -82,20 +82,56 @@ async function getUsersInOrganization(organizationId, where = {}) {
8282
}
8383
```
8484

85-
## API
85+
# API
8686

87-
### `` sql`query` ``
87+
## `` sql`query` ``
8888

8989
Creates arguments for `sequelize.query`.
9090

91-
#### Returns (`[string, {bind: Array<string>}]`)
91+
### Expressions you can embed in the template
92+
93+
#### Sequelize `Model` class
94+
Will be interpolated to the model's `tableName`.
95+
96+
#### Model attribute (e.g. `User.attributes.id`)
97+
Will be interpolated to the column name for the attribute
98+
99+
#### `` sql`nested` ``
100+
Good for conditionally including a SQL clause (see examples above)
101+
102+
#### `Sequelize.literal(...)`
103+
Text will be included as-is
104+
105+
#### All other values
106+
Will be added to bind parameters.
107+
108+
### Returns (`[string, {bind: Array<string>}]`)
92109
93110
The `sql, options` arguments to pass to `sequelize.query`.
94111
95-
### `` sql.escape`query` ``
112+
## `` sql.escape`query` ``
96113
97114
Creates a raw SQL string with all expressions in the template escaped.
98115
99-
#### Returns (`string`)
116+
### Expressions you can embed in the template
117+
118+
#### Sequelize `Model` class
119+
Will be interpolated to the model's `tableName`.
120+
121+
#### Model attribute (e.g. `User.attributes.id`)
122+
Will be interpolated to the column name for the attribute
123+
124+
#### `` sql`nested` ``
125+
Good for conditionally including a SQL clause (see examples above)
126+
127+
#### `Sequelize.literal(...)`
128+
Text will be included as-is
129+
130+
#### All other values
131+
Will be escaped with `QueryGenerator.escape(...)`. If none of the expressions
132+
is a Sequelize `Model` class or attribute (or nested `` sql`query` `` containing
133+
such) then an error will be thrown.
134+
135+
### Returns (`string`)
100136

101137
The raw SQL.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@
7979
"babel-preset-flow": "^6.23.0",
8080
"babel-preset-stage-1": "^6.24.1",
8181
"babel-register": "^6.23.0",
82-
"babel-runtime": "^6.23.0",
8382
"chai": "^4.1.2",
8483
"codecov": "^3.0.0",
8584
"copy": "^0.3.0",
@@ -101,5 +100,8 @@
101100
},
102101
"peerDependencies": {
103102
"sequelize": "^4.0.0"
103+
},
104+
"dependencies": {
105+
"babel-runtime": "^6.23.0"
104106
}
105107
}

src/index.js

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,95 @@
11
// @flow
22

3-
import Sequelize, {Model} from 'sequelize'
3+
import Sequelize, {Model, type QueryGenerator} from 'sequelize'
44

55
const Literal = Object.getPrototypeOf(Sequelize.literal('foo')).constructor
6+
const sqlOutput = Symbol('sqlOutput')
7+
const queryGeneratorSymbol = Symbol('queryGenerator')
68

79
function sql(
810
strings: $ReadOnlyArray<string>,
911
...expressions: $ReadOnlyArray<mixed>
1012
): [string, {bind: Array<any>}] {
1113
const parts: Array<string> = []
1214
const bind: Array<any> = []
15+
let queryGenerator
1316
for (let i = 0, length = expressions.length; i < length; i++) {
1417
parts.push(strings[i])
1518
const expression = expressions[i]
1619
if (expression instanceof Literal) {
1720
parts.push(expression.val)
21+
} else if (expression instanceof Object && expression[sqlOutput]) {
22+
const [query, options] = expression
23+
parts.push(query.replace(/(\$+)(\d+)/g, (match: string, dollars: string, index: string) =>
24+
dollars.length % 2 === 0
25+
? match
26+
: `${dollars}${parseInt(index) + bind.length}`
27+
))
28+
bind.push(...options.bind)
1829
} else if (expression && expression.prototype instanceof Model) {
1930
const {QueryGenerator, tableName} = (expression: any)
31+
queryGenerator = QueryGenerator
2032
parts.push(QueryGenerator.quoteTable(tableName))
2133
} else if (expression && expression.type instanceof Sequelize.ABSTRACT) {
2234
const {field, Model: {QueryGenerator}} = (expression: any)
35+
queryGenerator = QueryGenerator
2336
parts.push(QueryGenerator.quoteIdentifier(field))
2437
} else {
2538
bind.push(expression)
2639
parts.push(`$${bind.length}`)
2740
}
2841
}
2942
parts.push(strings[expressions.length])
30-
return [parts.join('').trim().replace(/\s+/g, ' '), {bind}]
43+
const result = [parts.join('').trim().replace(/\s+/g, ' '), {bind}];
44+
(result: any)[sqlOutput] = true
45+
if (queryGenerator) (result: any)[queryGeneratorSymbol] = queryGenerator
46+
return result
47+
}
48+
49+
function findQueryGenerator(expressions: $ReadOnlyArray<mixed>): QueryGenerator {
50+
for (let i = 0, {length} = expressions; i < length; i++) {
51+
const expression = expressions[i]
52+
if (expression instanceof Object && expression[queryGeneratorSymbol]) {
53+
return expression[queryGeneratorSymbol]
54+
} else if (expression && expression.prototype instanceof Model) {
55+
return (expression: any).QueryGenerator
56+
} else if (expression && expression.type instanceof Sequelize.ABSTRACT) {
57+
return (expression: any).Model.QueryGenerator
58+
}
59+
}
60+
throw new Error(`at least one of the expressions must be a sequelize Model or attribute`)
3161
}
3262

3363
sql.escape = function escapeSql(
3464
strings: $ReadOnlyArray<string>,
3565
...expressions: $ReadOnlyArray<mixed>
3666
): string {
3767
const parts: Array<string> = []
38-
let queryGenerator
39-
for (let i = 0, length = expressions.length; i < length; i++) {
68+
let queryGenerator: ?QueryGenerator
69+
function getQueryGenerator(): QueryGenerator {
70+
return queryGenerator || (queryGenerator = findQueryGenerator(expressions))
71+
}
72+
73+
for (let i = 0, {length} = expressions; i < length; i++) {
4074
parts.push(strings[i])
4175
const expression = expressions[i]
4276
if (expression instanceof Literal) {
4377
parts.push(expression.val)
78+
} else if (expression instanceof Object && expression[sqlOutput]) {
79+
const [query, options] = expression
80+
parts.push(query.replace(/(\$+)(\d+)/g, (match: string, dollars: string, index: string) =>
81+
dollars.length % 2 === 0
82+
? match
83+
: getQueryGenerator().escape(options.bind[parseInt(index) - 1])
84+
))
4485
} else if (expression && expression.prototype instanceof Model) {
45-
const {QueryGenerator, tableName} = (expression: any)
46-
queryGenerator = QueryGenerator
47-
parts.push(QueryGenerator.quoteTable(tableName))
86+
const {tableName} = (expression: any)
87+
parts.push(getQueryGenerator().quoteTable(tableName))
4888
} else if (expression && expression.type instanceof Sequelize.ABSTRACT) {
49-
const {field, Model: {QueryGenerator}} = (expression: any)
50-
queryGenerator = QueryGenerator
51-
parts.push(QueryGenerator.quoteIdentifier(field))
89+
const {field} = (expression: any)
90+
parts.push(getQueryGenerator().quoteIdentifier(field))
5291
} else {
53-
if (!queryGenerator) throw new Error(`at least one of the expressions must be a sequelize Model or attribute`)
54-
parts.push(queryGenerator.escape(expression))
92+
parts.push(getQueryGenerator().escape(expression))
5593
}
5694
}
5795
parts.push(strings[expressions.length])

test/index.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ describe(`sql`, function () {
1818
expect(sql`
1919
SELECT ${User.attributes.name} ${Sequelize.literal('FROM')} ${User}
2020
WHERE ${User.attributes.birthday} = ${new Date('2346-7-11')} AND
21+
${sql`${User.attributes.name} LIKE ${'a%'} AND`}${sql``}
2122
${User.attributes.id} = ${1}
22-
`).to.deep.equal([`SELECT "name" FROM "Users" WHERE "birthday" = $1 AND "id" = $2`, {bind: [new Date('2346-7-11'), 1]}])
23+
`).to.deep.equal([`SELECT "name" FROM "Users" WHERE "birthday" = $1 AND "name" LIKE $2 AND "id" = $3`, {
24+
bind: [new Date('2346-7-11'), 'a%', 1]
25+
}])
26+
})
27+
it(`handles escaped $ in nested templates properly`, function () {
28+
expect(sql`SELECT ${sql`'$$1'`}`).to.deep.equal([`SELECT '$$1'`, {bind: []}])
2329
})
2430
})
2531

@@ -35,10 +41,34 @@ describe(`sql.escape`, function () {
3541
expect(sql.escape`
3642
SELECT ${User.attributes.id} ${Sequelize.literal('FROM')} ${User}
3743
WHERE ${User.attributes.name} LIKE ${'and%'} AND
44+
${sql`${User.attributes.name} LIKE ${'a%'} AND`}${sql``}
3845
${User.attributes.id} = ${1}
39-
`).to.deep.equal(`SELECT "id" FROM "Users" WHERE "name" LIKE 'and%' AND "id" = 1`)
46+
`).to.deep.equal(`SELECT "id" FROM "Users" WHERE "name" LIKE 'and%' AND "name" LIKE 'a%' AND "id" = 1`)
4047
})
4148
it(`throws if it can't get a QueryGenerator`, function () {
4249
expect(() => sql.escape`SELECT ${1} + ${2};`).to.throw(Error, 'at least one of the expressions must be a sequelize Model or attribute')
4350
})
51+
it(`can get QueryGenerator from Sequelize Model class`, function () {
52+
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
53+
54+
const User = sequelize.define('User', {
55+
name: {type: Sequelize.STRING},
56+
birthday: {type: Sequelize.DATE},
57+
})
58+
59+
expect(sql.escape`SELECT ${'foo'} FROM ${User}`).to.deep.equal(`SELECT 'foo' FROM "Users"`)
60+
})
61+
it(`handles escaped $ in nested templates properly`, function () {
62+
expect(sql.escape`SELECT ${sql`'$$1'`}`).to.deep.equal(`SELECT '$$1'`)
63+
})
64+
it(`can get QueryGenerator from nested sql template`, async function (): Promise<void> {
65+
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
66+
67+
const User = sequelize.define('User', {
68+
name: {type: Sequelize.STRING},
69+
birthday: {type: Sequelize.DATE},
70+
})
71+
72+
expect(sql.escape`SELECT ${'foo'} FROM ${sql`${User}`}`).to.deep.equal(`SELECT 'foo' FROM "Users"`)
73+
})
4474
})

0 commit comments

Comments
 (0)