Skip to content

Commit 11eae0c

Browse files
committed
feat: add sql.with(sequelize).escape/values/literal/query
1 parent 9c791de commit 11eae0c

File tree

5 files changed

+2562
-9788
lines changed

5 files changed

+2562
-9788
lines changed

README.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ Good for conditionally including a SQL clause (see examples above)
102102
#### `Sequelize.literal(...)`
103103
Text will be included as-is
104104
105+
#### Arrays of `values` tagged template literals
106+
Will be included as-is joined by commas.
107+
105108
#### All other values
106109
Will be added to bind parameters.
107110
@@ -127,11 +130,68 @@ Good for conditionally including a SQL clause (see examples above)
127130
#### `Sequelize.literal(...)`
128131
Text will be included as-is
129132

133+
#### Arrays of `values` tagged template literals
134+
Will be included as-is joined by commas.
135+
130136
#### All other values
131137
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.
138+
is a Sequelize `Model` class, attribute, `Sequelize` instance, or nested `` sql`query` `` containing
139+
such, then an error will be thrown.
134140

135141
### Returns (`string`)
136142

137143
The raw SQL.
144+
145+
## `sql.with(sequelize)`
146+
147+
Returns an interface using the `QueryGenerator` from the given `Sequelize` instance.
148+
The returned interface has the following tagged template literals:
149+
150+
#### `` escape`query` ``
151+
152+
Just like `sql.escape`, but doesn't require any of the expressions to be a Sequelize `Model` class
153+
or attribute.
154+
155+
#### `` values`sql` ``
156+
157+
Used for building `VALUES` lists. Only works inside an array expression.
158+
The items will be included as-is joined by commas. For example:
159+
160+
```js
161+
const users = [
162+
{name: 'Jim', birthday: 'Jan 1 2020'},
163+
{name: 'Bob', birthday: 'Jan 2 1986'},
164+
]
165+
const {escape, values} = sql.with(sequelize)
166+
escape`
167+
INSERT INTO ${User}
168+
${User.attributes.name}, ${User.attributes.birthday}
169+
VALUES ${users.map(({name, birthday}) => values`(${name}, ${birthday})`)}
170+
`
171+
// returns `INSERT INTO "Users" "name", "birthday" VALUES ('Jim', 'Jan 1 2020'), ('Bob', 'Jan 2 1986')`
172+
```
173+
174+
#### `` literal`sql` ``
175+
176+
Like `sql.escape`, but wraps the escaped SQL in `Sequelize.literal`.
177+
178+
#### `` query`sql` ``
179+
180+
Returns a function that executes the query. Example:
181+
182+
```js
183+
const Sequelize = require('sequelize')
184+
const sql = require('@jcoreio/sequelize-sql-tag')
185+
const sequelize = new Sequelize('test', 'test', 'test', { dialect: 'postgres', logging: false })
186+
187+
const User = sequelize.define('User', {
188+
name: {type: Sequelize.STRING},
189+
})
190+
191+
async function insertUser(user) {
192+
const {query} = sql.with(sequelize)
193+
await query`
194+
INSERT INTO ${User} ${User.attributes.name} VALUES (${user.name});
195+
`({transaction})
196+
}
197+
```

src/index.js

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ const Literal = Object.getPrototypeOf(Sequelize.literal('foo')).constructor
88
const sqlOutput = Symbol('sqlOutput')
99
const queryGeneratorSymbol = Symbol('queryGenerator')
1010

11+
class ValuesRow {
12+
value: string
13+
constructor(value: string) {
14+
this.value = value
15+
}
16+
}
17+
18+
function isValuesArray(expression: any): boolean {
19+
if (!Array.isArray(expression)) return false
20+
for (let i = 0; i < expression.length; i++) {
21+
if (!(expression[i] instanceof ValuesRow)) return false
22+
}
23+
return true
24+
}
25+
1126
function sql(
1227
strings: $ReadOnlyArray<string>,
1328
...expressions: $ReadOnlyArray<mixed>
@@ -20,6 +35,8 @@ function sql(
2035
const expression = expressions[i]
2136
if (expression instanceof Literal) {
2237
parts.push(expression.val)
38+
} else if (isValuesArray(expression)) {
39+
parts.push((expression: any).map(row => row.value).join(', '))
2340
} else if (expression instanceof Object && expression[sqlOutput]) {
2441
const [query, options] = expression
2542
parts.push(query.replace(/(\$+)(\d+)/g, (match: string, dollars: string, index: string) =>
@@ -57,45 +74,93 @@ function findQueryGenerator(expressions: $ReadOnlyArray<mixed>): QueryGenerator
5774
return (expression: any).QueryGenerator
5875
} else if (expression && expression.type instanceof Sequelize.ABSTRACT) {
5976
return (expression: any).Model.QueryGenerator
77+
} else if (expression instanceof Sequelize) {
78+
return expression.getQueryInterface().QueryGenerator
6079
}
6180
}
62-
throw new Error(`at least one of the expressions must be a sequelize Model or attribute`)
81+
throw new Error(`at least one of the expressions must be a sequelize Model, attribute, or Sequelize instance`)
82+
}
83+
84+
const once = <F: Function>(fn: F): F => {
85+
let called = false
86+
let result: any
87+
return ((): any => {
88+
if (called) return result
89+
called = true
90+
return result = fn()
91+
}: any)
6392
}
6493

65-
sql.escape = function escapeSql(
94+
const escapeSql = (
95+
queryGenerator: () => QueryGenerator,
96+
) => (
6697
strings: $ReadOnlyArray<string>,
6798
...expressions: $ReadOnlyArray<mixed>
68-
): string {
99+
): string => {
69100
const parts: Array<string> = []
70-
let queryGenerator: ?QueryGenerator
71-
function getQueryGenerator(): QueryGenerator {
72-
return queryGenerator || (queryGenerator = findQueryGenerator(expressions))
73-
}
74-
75101
for (let i = 0, {length} = expressions; i < length; i++) {
76102
parts.push(strings[i])
77103
const expression = expressions[i]
78104
if (expression instanceof Literal) {
79105
parts.push(expression.val)
106+
} else if (isValuesArray(expression)) {
107+
parts.push((expression: any).map(row => row.value).join(', '))
80108
} else if (expression instanceof Object && expression[sqlOutput]) {
81109
const [query, options] = expression
82110
parts.push(query.replace(/(\$+)(\d+)/g, (match: string, dollars: string, index: string) =>
83111
dollars.length % 2 === 0
84112
? match
85-
: getQueryGenerator().escape(options.bind[parseInt(index) - 1])
113+
: queryGenerator().escape(options.bind[parseInt(index) - 1])
86114
))
87115
} else if (expression && expression.prototype instanceof Model) {
88116
const {tableName} = (expression: any)
89-
parts.push(getQueryGenerator().quoteTable(tableName))
117+
parts.push(queryGenerator().quoteTable(tableName))
90118
} else if (expression && expression.type instanceof Sequelize.ABSTRACT) {
91119
const {field} = (expression: any)
92-
parts.push(getQueryGenerator().quoteIdentifier(field))
120+
parts.push(queryGenerator().quoteIdentifier(field))
93121
} else {
94-
parts.push(getQueryGenerator().escape(expression))
122+
parts.push(queryGenerator().escape(expression))
95123
}
96124
}
97125
parts.push(strings[expressions.length])
98-
return parts.join('').trim().replace(/\s+/g, ' ')
126+
return parts.join('').trim()
99127
}
100128

129+
sql.escape = (
130+
strings: $ReadOnlyArray<string>,
131+
...expressions: $ReadOnlyArray<mixed>
132+
): string => escapeSql(once(() => findQueryGenerator(expressions)))(strings, ...expressions)
133+
134+
sql.literal = (
135+
strings: $ReadOnlyArray<string>,
136+
...expressions: $ReadOnlyArray<mixed>
137+
): Literal => Sequelize.literal(escapeSql(once(() => findQueryGenerator(expressions)))(strings, ...expressions))
138+
139+
sql.with = (
140+
sequelize: Sequelize
141+
) => ({
142+
escape: (
143+
strings: $ReadOnlyArray<string>,
144+
...expressions: $ReadOnlyArray<mixed>
145+
): string => escapeSql(() => sequelize.getQueryInterface().QueryGenerator)(strings, ...expressions),
146+
values: (
147+
strings: $ReadOnlyArray<string>,
148+
...expressions: $ReadOnlyArray<mixed>
149+
): ValuesRow => new ValuesRow(escapeSql(() => sequelize.getQueryInterface().QueryGenerator)(strings, ...expressions)),
150+
literal: (
151+
strings: $ReadOnlyArray<string>,
152+
...expressions: $ReadOnlyArray<mixed>
153+
): Literal => Sequelize.literal(escapeSql(() => sequelize.getQueryInterface().QueryGenerator)(strings, ...expressions)),
154+
query: (
155+
strings: $ReadOnlyArray<string>,
156+
...expressions: $ReadOnlyArray<mixed>
157+
) => (options: QueryOptions = {}): Promise<any> => {
158+
const [query, baseOptions] = sql(strings, ...expressions)
159+
return sequelize.query(
160+
query,
161+
{...baseOptions, ...options}
162+
)
163+
}
164+
})
165+
101166
module.exports = sql

test/index.js

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import {expect} from 'chai'
77
import sql from '../src'
88

99
describe(`sql`, function () {
10-
it(`works`, function () {
11-
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
10+
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
1211

13-
const User = sequelize.define('User', {
14-
name: {type: Sequelize.STRING},
15-
birthday: {type: Sequelize.DATE},
16-
})
12+
const User = sequelize.define('User', {
13+
name: {type: Sequelize.STRING},
14+
birthday: {type: Sequelize.DATE},
15+
})
1716

17+
it(`works`, function () {
1818
expect(sql`
1919
SELECT ${User.attributes.name} ${Sequelize.literal('FROM')} ${User}
2020
WHERE ${User.attributes.birthday} = ${new Date('2346-7-11')} AND
@@ -27,6 +27,22 @@ WHERE ${User.attributes.birthday} = ${new Date('2346-7-11')} AND
2727
it(`handles escaped $ in nested templates properly`, function () {
2828
expect(sql`SELECT ${sql`'$$1'`}`).to.deep.equal([`SELECT '$$1'`, {bind: []}])
2929
})
30+
it(`works with nested sql.literal`, function () {
31+
expect(sql`SELECT ${sql.with(sequelize).literal`${'foo'}`} FROM ${User}`).to.deep.equal([`SELECT 'foo' FROM "Users"`, {bind: []}])
32+
})
33+
it(`works with nested .values`, function () {
34+
const {values} = sql.with(sequelize)
35+
const users = [
36+
{name: 'Jim', birthday: 'Jan 1 2020'},
37+
{name: 'Bob', birthday: 'Jan 2 1986'},
38+
]
39+
expect(sql`
40+
INSERT INTO ${User}
41+
${User.attributes.name}, ${User.attributes.birthday}
42+
VALUES ${users.map(({name, birthday}) => values`(${name}, ${birthday})`)}
43+
`).to.deep.equal([
44+
`INSERT INTO "Users" "name", "birthday" VALUES ('Jim', 'Jan 1 2020'), ('Bob', 'Jan 2 1986')`, {bind: []}])
45+
})
3046
})
3147

3248
describe(`sql.escape`, function () {
@@ -43,10 +59,13 @@ SELECT ${User.attributes.id} ${Sequelize.literal('FROM')} ${User}
4359
WHERE ${User.attributes.name} LIKE ${'and%'} AND
4460
${sql`${User.attributes.name} LIKE ${'a%'} AND`}${sql``}
4561
${User.attributes.id} = ${1}
46-
`).to.deep.equal(`SELECT "id" FROM "Users" WHERE "name" LIKE 'and%' AND "name" LIKE 'a%' AND "id" = 1`)
62+
`).to.deep.equal(`SELECT "id" FROM "Users"
63+
WHERE "name" LIKE 'and%' AND
64+
"name" LIKE 'a%' AND
65+
"id" = 1`)
4766
})
4867
it(`throws if it can't get a QueryGenerator`, function () {
49-
expect(() => sql.escape`SELECT ${1} + ${2};`).to.throw(Error, 'at least one of the expressions must be a sequelize Model or attribute')
68+
expect(() => sql.escape`SELECT ${1} + ${2};`).to.throw(Error, 'at least one of the expressions must be a sequelize Model, attribute, or Sequelize instance')
5069
})
5170
it(`can get QueryGenerator from Sequelize Model class`, function () {
5271
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
@@ -71,4 +90,35 @@ WHERE ${User.attributes.name} LIKE ${'and%'} AND
7190

7291
expect(sql.escape`SELECT ${'foo'} FROM ${sql`${User}`}`).to.deep.equal(`SELECT 'foo' FROM "Users"`)
7392
})
93+
describe(`.with`, function () {
94+
const sequelize = new Sequelize('test', 'test', 'test', {dialect: 'postgres'})
95+
96+
describe(`.escape`, function () {
97+
it(`works`, function () {
98+
expect(sql.with(sequelize).escape`SELECT LOWER(${'foo'});`).to.deep.equal(`SELECT LOWER('foo');`)
99+
})
100+
it(`works in conjunction with .values`, function () {
101+
const items = [
102+
{foo: 1, bar: {hello: 'world'}},
103+
{foo: 3, bar: 'baz'},
104+
]
105+
const {escape, values} = sql.with(sequelize)
106+
expect(escape`SELECT VALUES ${items.map(({foo, bar}) => values`(${foo}, ${JSON.stringify(bar)}::jsonb)`)}`)
107+
.to.equal(`SELECT VALUES (1, '{"hello":"world"}'::jsonb), (3, '"baz"'::jsonb)`)
108+
})
109+
})
110+
describe(`.query`, function () {
111+
it(`works`, function () {
112+
const calls = []
113+
const _sequelize: any = {query: (...args: any) => {
114+
calls.push(args)
115+
return Promise.resolve()
116+
}, getQueryInterface(): any {return sequelize.getQueryInterface()}}
117+
expect(sql.with(_sequelize).query`SELECT LOWER(${'foo'});`({test: 'bar'})).to.be.an.instanceOf(Promise)
118+
expect(calls).to.deep.equal([
119+
['SELECT LOWER($1);', {bind: ['foo'], test: 'bar'}]
120+
])
121+
})
122+
})
123+
})
74124
})

0 commit comments

Comments
 (0)