@cap-js/postgres 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@
4
4
  - The format is based on [Keep a Changelog](http://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## Version 1.2.0 - 2023-09-06
8
+
9
+ ### Added
10
+
11
+ - Reduced the usage of `is not distinct [not] from`. #157
12
+
13
+ ### Fixed
14
+
15
+ - [Reserved words](https://www.postgresql.org/docs/current/sql-keywords-appendix.html) are now used to automatically escape reserved words which are used as identifier. #178
16
+ - Remove column count limitation. #150
17
+
18
+ ## Version 1.1.0 - 2023-08-01
19
+
20
+ ### Added
21
+
22
+ - Connectivity to Azure PostgreSQL.
23
+
24
+ ### Fixed
25
+
26
+ - Order by collation waterfall:
27
+ 1. ICU
28
+ 2. best-effort mapping (`xx` -> `xx_YY`, where `xx_YY` is the first match)
29
+ 3. without collation
30
+ - More stable configuration of `schema_evolution = 'auto'`.
31
+ - Log `hostname` preferrably during deployment.
32
+ - Allow overriding of pool configuration.
33
+
34
+ ### Changed
35
+
36
+ - Session context variables are set as lower case instead of upper case.
37
+
7
38
  ## Version 1.0.1 - 2023-07-03
8
39
 
9
40
  ### Added
@@ -16,4 +47,4 @@
16
47
 
17
48
  ## Version 1.0.0 - 2023-06-23
18
49
 
19
- - Initial Release
50
+ - Initial Release
package/README.md CHANGED
@@ -1,5 +1,153 @@
1
1
  # CDS database service for Postgres
2
2
 
3
3
  Welcome to the new Postgres database service for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js, based on new, streamlined database architecture and [*pg* driver](https://www.npmjs.com/package/pg) .
4
+ Find full documentation at https://cap.cloud.sap/docs/guides/databases-postgres.
4
5
 
5
- Find documentation at https://cap.cloud.sap/docs/guides/databases-postgres.
6
+ ## migration guide from `cds-pg` to `@cap-js/postgres`
7
+
8
+ `@cap-js/postgres` works as a drop-in replacement for `cds-pg`.
9
+ However, some preliminary checks and cleanups help:
10
+
11
+ - for using the BTP Postgres Hyperscaler as database,
12
+ - know that the credentials are picked up automatically by from the enviornment (`VCAP_SERVICES.postgres`)
13
+ - the service binding label is `postgresql-db`
14
+ - `cds-dbm` is replaced by a hand-crafted "db-deployer" app → see below
15
+ - your local `package.json`: you can safely remove the entry `cds.requires.postgres` previously mandatory for `cds-pg`
16
+ - recommendation: set the env var `DEBUG=sql` during local development to see DB-level output from PostgreSQL
17
+
18
+ ### schema migration
19
+
20
+ `@cap-js/postgres` brings the same schema evolution capabilities to PostgreSQL known from HANA and SQLite.
21
+ Enabling schema migration in an existing `cds-pg`-based project consists of generating and deploying a "csn-snapshot" of your database structure.
22
+
23
+ #### local development
24
+
25
+ First, set a basis for the evolution
26
+ `$> cds deploy --model-only`
27
+ → this will create the table `cds_model` laying the foundation for the schema migration
28
+
29
+ Subsequent deployments can then re-use the standard deploy mechanism via `$> cds deploy`
30
+
31
+ #### On BTP, Cloud Foundry environment
32
+
33
+ The above "csn-snapshots" can be implemented via the `mtar`-based approach. At the same time, the same `mtar` can be used for subsequent PostgreSQL deployments (with schema evolution).
34
+
35
+ Two major steps in addition to enabling the schema evolution are included in this `mtar`.
36
+
37
+ 1. create local folder `deployer` (any name works)
38
+ 2. in `deployer`, create a `package.json` containing
39
+
40
+ ```json
41
+ ...
42
+ "//npm run migrate": "only one-time!",
43
+ "migrate": "cds deploy --model-only",
44
+ "//npm run deploy": "subsequent deployments",
45
+ "deploy": "cds deploy"
46
+ ...
47
+ ```
48
+
49
+ 3. add a section to your `/mta.yaml` denoting the `deployer` directory as a standalone application that runs one-time
50
+
51
+ ```yaml
52
+ - name: pg-db-deployer
53
+ type: custom
54
+ path: deployer
55
+ parameters:
56
+ buildpacks: nodejs_buildpack
57
+ no-route: true
58
+ no-start: true
59
+ disk-quota: 2GB
60
+ memory: 512MB
61
+ tasks:
62
+ - name: migrate
63
+ command: npm run migrate
64
+ # # for subsequent deployments
65
+ # - name: deploy
66
+ # command: npm run deploy
67
+ disk-quota: 2GB
68
+ memory: 512MB
69
+ build-parameters:
70
+ before-all:
71
+ custom:
72
+ - npm i
73
+ # generate the "csn-snapshot" - only necessary for one-time migration,
74
+ # can be commented out on subsequent deployments
75
+ - cds compile '*' -2 json > deployer/schema.csn
76
+ ignore: ["node_modules/"]
77
+ requires:
78
+ - name: pg-database
79
+
80
+ resources:
81
+ - name: pg-database
82
+ parameters:
83
+ path: ./pg-options.json
84
+ service: postgresql-db
85
+ service-plan: trial # change to yours!
86
+ skip-service-updates:
87
+ parameters: true
88
+ service-tags:
89
+ - plain
90
+ type: org.cloudfoundry.managed-service
91
+ ```
92
+
93
+ ## migration points to consider
94
+
95
+ ### mixed-case identifiers
96
+
97
+ even though column names that are not double-quoted are folded to lowercase in PostgreSQL (`yourName` -> `yourname`, `"yourName"` -> `yourName`),
98
+ you can use the mixed case definitions from your `.cds` files to reference them.
99
+
100
+ example: `brewery_id` on DB level -> `brewery_ID` on CDS level
101
+
102
+ formerly w/ `cds-pg` you had to follow the DB level: `SELECT.from(Beers).columns('brewery_id').groupBy('brewery_id')`
103
+ now, re-use the CDS definitions: `SELECT.from(Beers).columns('brewery_ID').groupBy('brewery_ID')`
104
+
105
+ So please adjust your `CQL` statements accordingly.
106
+
107
+ ### timezones (potential _**BREAKING CHANGE**_)
108
+
109
+ any date- + time-type will get stored in [`UTC`](https://en.wikipedia.org/wiki/Coordinated_Universal_Time) **without any timezone identifier in the actual data field**.
110
+ CAP's inbound- and outbound adapters take care of converting incoming and outgoing data from/to the desired time zones.
111
+ So when a `dateime` comes in being in [an ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) compatible format
112
+ `2009-01-01T15:00:00+01:00` (15:00:00 on January 1 2009 in Vienna (CEST))
113
+ will get stored as
114
+ `2009-01-01T13:00:00` (13:00:00 on January 1 2009 in UTC).
115
+
116
+ Please be aware of that concept and rely on the client to parse UTC in your desired timezone (format).
117
+
118
+ ### `cds.DatabaseService` consumption
119
+
120
+ `InsertResult` now does only return the affected rows and their `ID`s.
121
+
122
+ ```js
123
+ const entries = [
124
+ { name: 'Beer1', /* ... */ },
125
+ { name: 'Beer2', /* ... */ },
126
+ { name: 'Beer3', /* ... */ }
127
+ ]
128
+ const insertResult = await cds.run(INSERT.into(Beers).entries(entries))
129
+ expect(insertResult.affectedRows).to.equal(3)
130
+ const beers = [...insertResult] //> this calls the [Symbol.iterator] method of the insert result
131
+ // beers:
132
+ // [
133
+ // { ID: "f81d7ee5-922b-48a1-a12a-a899b8594c99" },
134
+ // { ID: "ddda7f8e-e26b-430f-a80c-ac2c7df29510" },
135
+ // { ID: "7228c40f-0046-4f53-8a2b-3d55ad825f59" }
136
+ // ]
137
+ ```
138
+
139
+ In `cds-pg`, we additionally surfaced the entire inserted dataset.
140
+
141
+ ```js
142
+ // continuing after the insert of the above example:
143
+ // const insertResult = await cds.run(INSERT.into(Beers).entries(entries))
144
+
145
+ // this works NO MORE - see above
146
+ const beers = insertResult.results
147
+ expect(beers.length).toStrictEqual(3)
148
+ expect(beers[0].ID).toMatch(uuidRegex)
149
+ expect(beers[0].createdAt.toISOString()).toMatch(timestampRegex)
150
+ expect(beers[0].modifiedAt.toISOString()).toMatch(timestampRegex)
151
+ ```
152
+
153
+ So please adjust your runtime coding accordingly.
@@ -1,7 +1,9 @@
1
1
  const { SQLService } = require('@cap-js/db-service')
2
- const { Client } = require('pg')
2
+ const { Client, Query } = require('pg')
3
3
  const cds = require('@sap/cds/lib')
4
4
  const crypto = require('crypto')
5
+ const { Writable, Readable } = require('stream')
6
+ const sessionVariableMap = require('./session.json')
5
7
 
6
8
  class PostgresService extends SQLService {
7
9
  init() {
@@ -17,11 +19,11 @@ class PostgresService extends SQLService {
17
19
  get factory() {
18
20
  return {
19
21
  options: {
20
- ...this.options.pool,
21
22
  min: 0,
22
23
  testOnBorrow: true,
23
24
  acquireTimeoutMillis: 1000,
24
25
  destroyTimeoutMillis: 1000,
26
+ ...this.options.pool,
25
27
  },
26
28
  create: async () => {
27
29
  const cr = this.options.credentials || {}
@@ -36,10 +38,14 @@ class PostgresService extends SQLService {
36
38
  database: cr.dbname || cr.database,
37
39
  schema: cr.schema,
38
40
  sslRequired: cr.sslrootcert && (cr.sslrootcert ?? true),
39
- ssl: cr.sslrootcert && {
40
- rejectUnauthorized: false,
41
- ca: cr.sslrootcert,
42
- },
41
+ // from pg driver docs:
42
+ // passed directly to node.TLSSocket, supports all tls.connect options
43
+ ssl:
44
+ cr.ssl /* enable pg module setting to connect to Azure postgres */ ||
45
+ (cr.sslrootcert && {
46
+ rejectUnauthorized: false,
47
+ ca: cr.sslrootcert,
48
+ }),
43
49
  }
44
50
  const dbc = new Client(credentials)
45
51
  await dbc.connect()
@@ -52,23 +58,23 @@ class PostgresService extends SQLService {
52
58
 
53
59
  url4() {
54
60
  // TODO: Maybe log which database and which user? Be more robust against missing properties?
55
- let { host, port } = this.options?.credentials || this.options || {}
56
- return host + ':' + (port || 5432)
61
+ let { host, hostname, port } = this.options?.credentials || this.options || {}
62
+ return (hostname || host) + ':' + (port || 5432)
57
63
  }
58
64
 
59
65
  async set(variables) {
60
- // REVISIT: remove when all environment variables are aligned
61
66
  // RESTRICTIONS: 'Custom parameter names must be two or more simple identifiers separated by dots.'
62
- const nameMap = {
63
- '$user.id': 'CAP.APPLICATIONUSER',
64
- '$user.locale': 'CAP.LOCALE',
65
- '$valid.from': 'CAP.VALID_FROM',
66
- '$valid.to': 'CAP.VALID_TO',
67
- }
68
-
69
67
  const env = {}
68
+
69
+ // Check all properties on the variables object
70
70
  for (let name in variables) {
71
- env[nameMap[name]] = variables[name]
71
+ env[sessionVariableMap[name] || name] = variables[name]
72
+ }
73
+
74
+ // Explicitly check for the default session variable properties
75
+ // As they are getters and not own properties of the object
76
+ for (let name in sessionVariableMap) {
77
+ if (variables[name]) env[sessionVariableMap[name]] = variables[name]
72
78
  }
73
79
 
74
80
  return Promise.all([
@@ -79,24 +85,58 @@ class PostgresService extends SQLService {
79
85
  ? [this.exec(`SET search_path TO "${this.options?.credentials?.schema}";`)]
80
86
  : []),
81
87
 
82
- ...(!this._initalCollateCheck
83
- ? [
84
- (await this.prepare(`SELECT collname FROM pg_collation WHERE collname = 'en_US' OR collname ='en-x-icu';`))
85
- .all([])
86
- .then(resp => {
87
- this._initalCollateCheck = true
88
- if (resp.find(row => row.collname === 'en_US')) return
89
- if (resp.find(row => row.collname === 'en-x-icu'))
90
- this.class.CQN2SQL.prototype.orderBy = this.class.CQN2SQL.prototype.orderByICU
91
- // REVISIT throw error when there is no collated libary found
92
- }),
93
- ]
94
- : []),
88
+ ...(!this._initalCollateCheck ? [this._checkCollation()] : []),
95
89
  ])
96
90
  }
97
91
 
92
+ async _checkCollation() {
93
+ this._initalCollateCheck = true
94
+
95
+ const icuPrep = await this.prepare(`SELECT collname FROM pg_collation WHERE collname = 'en-x-icu';`)
96
+ const icuResp = await icuPrep.all([])
97
+
98
+ if (icuResp.length > 0) {
99
+ this.class.CQN2SQL.prototype.orderBy = this.class.CQN2SQL.prototype.orderByICU
100
+ return
101
+ }
102
+
103
+ /**
104
+ * Selects the first two characters of the collation name as key
105
+ * Select the smallest collation name as value (could also be max)
106
+ * Filter the collations by the provider c (libc)
107
+ * Filters the collation names by /.._../ Where '>' points at the '_' that is an actual '_'
108
+ * The group by is done by the key column to make sure that only one collation per key is returned
109
+ */
110
+ const cSQL = `
111
+ SELECT
112
+ SUBSTRING(collname, 1, 2) AS K,
113
+ MIN(collname) AS V
114
+ FROM
115
+ pg_collation
116
+ WHERE
117
+ collprovider = 'c' AND
118
+ collname LIKE '__>___' ESCAPE '>'
119
+ GROUP BY k
120
+ `
121
+
122
+ const cPrep = await this.prepare(cSQL)
123
+ const cResp = await cPrep.all([])
124
+ if (cResp.length > 0) {
125
+ const collationMap = (this.class.CQN2SQL.prototype.collationMap = cResp.reduce((ret, row) => {
126
+ ret[row.k] = row.v
127
+ return ret
128
+ }, {}))
129
+ collationMap.default = collationMap.en || collationMap[Object.keys(collationMap)[0]]
130
+ this.class.CQN2SQL.prototype.orderBy = this.class.CQN2SQL.prototype.orderByLIBC
131
+ return
132
+ }
133
+
134
+ // REVISIT: print a warning when no collation is found
135
+ }
136
+
98
137
  prepare(sql) {
99
138
  const query = {
139
+ _streams: 0,
100
140
  text: sql,
101
141
  // Track queries name for postgres referencing prepare statements
102
142
  // sha1 as it needs to be less then 63 characters
@@ -105,7 +145,9 @@ class PostgresService extends SQLService {
105
145
  return {
106
146
  run: async values => {
107
147
  // REVISIT: SQLService provides empty values as {} for plain SQL statements - PostgreSQL driver expects array or nothing - see issue #78
108
- const result = await this.dbc.query({ ...query, values: this._getValues(values) })
148
+ let newQuery = this._prepareStreams(query, values)
149
+ if (typeof newQuery.then === 'function') newQuery = await newQuery
150
+ const result = await this.dbc.query(newQuery)
109
151
  return { changes: result.rowCount }
110
152
  },
111
153
  get: async values => {
@@ -122,6 +164,14 @@ class PostgresService extends SQLService {
122
164
  throw Object.assign(e, { sql: sql + '\n' + new Array(e.position).fill(' ').join('') + '^' })
123
165
  }
124
166
  },
167
+ stream: async (values, one) => {
168
+ try {
169
+ const streamQuery = new QueryStream({ ...query, values: this._getValues(values) }, one)
170
+ return await this.dbc.query(streamQuery)
171
+ } catch (e) {
172
+ throw Object.assign(e, { sql: sql + '\n' + new Array(e.position).fill(' ').join('') + '^' })
173
+ }
174
+ },
125
175
  }
126
176
  }
127
177
 
@@ -133,6 +183,60 @@ class PostgresService extends SQLService {
133
183
  return values
134
184
  }
135
185
 
186
+ _prepareStreams(query, values) {
187
+ values = this._getValues(values)
188
+ if (!values) return query
189
+
190
+ const streams = []
191
+ const newValues = []
192
+ let sql = query.text
193
+ if (Array.isArray(values)) {
194
+ values.forEach((value, i) => {
195
+ if (value instanceof Readable) {
196
+ const streamID = query._streams++
197
+ const isBinary = value.type === 'binary'
198
+ const paramStream = new ParameterStream(query.name, streamID)
199
+ if (isBinary) value.setEncoding('base64')
200
+ value.pipe(paramStream)
201
+ value.on('error', err => paramStream.emit('error', err))
202
+ streams[i] = paramStream
203
+ newValues[i] = streamID
204
+ sql = sql.replace(
205
+ new RegExp(`\\$${i + 1}`, 'g'),
206
+ // Don't ask about the dollar signs
207
+ `(SELECT ${isBinary ? `DECODE(PARAM,'base64')` : 'PARAM'} FROM "$$$$PARAMETER_BUFFER$$$$" WHERE NAME='${
208
+ query.name
209
+ }' AND ID=$${i + 1})`,
210
+ )
211
+ return
212
+ }
213
+ newValues[i] = value
214
+ })
215
+ }
216
+
217
+ if (streams.length > 0) {
218
+ return (async () => {
219
+ const newQuery = {
220
+ text: sql,
221
+ // Even with the changed SQL it might be common to call this statement with the same parameters as streams
222
+ // As the streams are selected with their ID as prepared statement parameter, the sql is the same
223
+ name: crypto.createHash('sha1').update(sql).digest('hex'),
224
+ values: newValues,
225
+ }
226
+ await this.dbc.query({
227
+ text: 'CREATE TEMP TABLE IF NOT EXISTS "$$PARAMETER_BUFFER$$" (PARAM TEXT, NAME TEXT, ID INT) ON COMMIT DROP',
228
+ })
229
+ const proms = []
230
+ for (const stream of streams) {
231
+ proms.push(this.dbc.query(stream))
232
+ }
233
+ await Promise.all(proms)
234
+ return newQuery
235
+ })()
236
+ }
237
+ return { ...query, values }
238
+ }
239
+
136
240
  async exec(sql) {
137
241
  return this.dbc.query(sql)
138
242
  }
@@ -178,26 +282,29 @@ class PostgresService extends SQLService {
178
282
  }
179
283
 
180
284
  static CQN2SQL = class CQN2Postgres extends SQLService.CQN2SQL {
181
- orderBy(orderBy, localized) {
285
+ _orderBy(orderBy, localized, locale) {
182
286
  return orderBy.map(
183
287
  localized
184
288
  ? c =>
185
289
  this.expr(c) +
186
- (c.element?.[this.class._localized] ? ` COLLATE "${this.context.locale}"` : '') +
290
+ (c.element?.[this.class._localized] ? ` COLLATE "${locale}"` : '') +
187
291
  (c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
188
292
  : c => this.expr(c) + (c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC'),
189
293
  )
190
294
  }
191
295
 
296
+ orderBy(orderBy) {
297
+ return this._orderBy(orderBy)
298
+ }
299
+
192
300
  orderByICU(orderBy, localized) {
193
- return orderBy.map(
194
- localized
195
- ? c =>
196
- this.expr(c) +
197
- (c.element?.[this.class._localized] ? ` COLLATE "${this.context.locale.replace('_', '-')}-x-icu"` : '') +
198
- (c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
199
- : c => this.expr(c) + (c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC'),
200
- )
301
+ const locale = `${this.context.locale.replace('_', '-')}-x-icu`
302
+ return this._orderBy(orderBy, localized, locale)
303
+ }
304
+
305
+ orderByLIBC(orderBy, localized) {
306
+ const locale = this.collationMap[this.context.locale] || this.collationMap.default
307
+ return this._orderBy(orderBy, localized && locale, locale)
201
308
  }
202
309
 
203
310
  from(from) {
@@ -209,18 +316,9 @@ class PostgresService extends SQLService {
209
316
  return super.from(from)
210
317
  }
211
318
 
212
- // REVISIT: pg requires alias for {val}
213
- SELECT_columns({ SELECT }) {
214
- // REVISIT: Genres cqn has duplicate ID column
215
- if (!SELECT.columns) return '*'
216
- const unique = {}
217
- return SELECT.columns
218
- .map(x => `${this.column_expr(x)} as ${this.quote(this.column_name(x))}`)
219
- .filter(x => {
220
- if (unique[x]) return false
221
- unique[x] = true
222
- return true
223
- })
319
+ column_alias4(x, q) {
320
+ if (!x.as && 'val' in x) return String(x.val)
321
+ return super.column_alias4(x, q)
224
322
  }
225
323
 
226
324
  SELECT_expand({ SELECT }, sql) {
@@ -228,19 +326,25 @@ class PostgresService extends SQLService {
228
326
  const queryAlias = this.quote(SELECT.from?.as || (SELECT.expand === 'root' && 'root'))
229
327
  const cols = SELECT.columns.map(x => {
230
328
  const name = this.column_name(x)
231
- let col = `${this.string(name)},${this.output_converter4(x.element, queryAlias + '.' + this.quote(name))}`
329
+ const outputConverter = this.output_converter4(x.element, `${queryAlias}.${this.quote(name)}`)
330
+ let col = `${outputConverter} as ${this.doubleQuote(name)}`
232
331
 
233
332
  if (x.SELECT?.count) {
234
333
  // Return both the sub select and the count for @odata.count
235
334
  const qc = cds.ql.clone(x, { columns: [{ func: 'count' }], one: 1, limit: 0, orderBy: 0 })
236
- col += `, '${name}@odata.count',${this.expr(qc)}`
335
+ col += `,${this.expr(qc)} as ${this.doubleQuote(`${name}@odata.count`)}`
237
336
  }
238
337
  return col
239
338
  })
240
- let obj = `json_build_object(${cols})`
339
+ // REVISIT: Remove SELECT ${cols} by adjusting SELECT_columns
340
+ let obj = `row_to_json(${queryAlias}.*)`
241
341
  return `SELECT ${
242
342
  SELECT.one || SELECT.expand === 'root' ? obj : `coalesce(json_agg(${obj}),'[]'::json)`
243
- } as _json_ FROM (${sql}) as ${queryAlias}`
343
+ } as _json_ FROM (SELECT ${cols} FROM (${sql}) as ${queryAlias}) as ${queryAlias}`
344
+ }
345
+
346
+ doubleQuote(name) {
347
+ return `"${name.replace(/"/g, '""')}"`
244
348
  }
245
349
 
246
350
  INSERT(q, isUpsert = false) {
@@ -251,20 +355,24 @@ class PostgresService extends SQLService {
251
355
  // Adjusts json path expressions to be postgres specific
252
356
  .replace(/->>'\$(?:(?:\."(.*?)")|(?:\[(\d*)\]))'/g, (a, b, c) => (b ? `->>'${b}'` : `->>${c}`))
253
357
  // Adjusts json function to be postgres specific
254
- .replace('json_each(?)', 'json_array_elements($1)')
358
+ .replace('json_each(?)', 'json_array_elements($1::JSON)')
255
359
  .replace(/json_type\((\w+),'\$\."(\w+)"'\)/g, (_a, b, c) => `json_typeof(${b}->'${c}')`))
256
360
  }
257
361
 
362
+ param({ ref }) {
363
+ this._paramCount = this._paramCount || 1
364
+ if (ref.length > 1) throw cds.error`Unsupported nested ref parameter: ${ref}`
365
+ return ref[0] === '?' ? `$${this._paramCount++}` : `:${ref}`
366
+ }
367
+
258
368
  val(val) {
259
369
  const ret = super.val(val)
260
370
  return ret === '?' ? `$${this.values.length}` : ret
261
371
  }
262
372
 
263
- operator(x) {
373
+ operator(x, i, xpr) {
264
374
  if (x === 'regexp') return '~'
265
- if (x === '=') return 'is not distinct from'
266
- if (x === '!=') return 'is distinct from'
267
- else return x
375
+ else return super.operator(x, i, xpr)
268
376
  }
269
377
 
270
378
  defaultValue(defaultValue = this.context.timestamp.toISOString()) {
@@ -306,14 +414,14 @@ class PostgresService extends SQLService {
306
414
  // REVISIT: Remove that with upcomming fixes in cds.linked
307
415
  Double: (e, t) => `CAST(${e} as decimal${t.precision && t.scale ? `(${t.precision},${t.scale})` : ''})`,
308
416
  DecimalFloat: (e, t) => `CAST(${e} as decimal${t.precision && t.scale ? `(${t.precision},${t.scale})` : ''})`,
309
- Binary: e => `CAST(${e} as bytea)`,
310
- LargeBinary: e => `CAST(${e} as bytea)`,
417
+ Binary: e => `DECODE(${e},'base64')`,
418
+ LargeBinary: e => `DECODE(${e},'base64')`,
311
419
  }
312
420
 
313
421
  static OutputConverters = {
314
422
  ...super.OutputConverters,
315
- Binary: e => e,
316
- LargeBinary: e => e,
423
+ Binary: e => `ENCODE(${e},'base64')`,
424
+ LargeBinary: e => `ENCODE(${e},'base64')`,
317
425
  Date: e => `to_char(${e}, 'YYYY-MM-DD')`,
318
426
  Time: e => `to_char(${e}, 'HH24:MI:SS')`,
319
427
  DateTime: e => `to_char(${e}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
@@ -363,7 +471,7 @@ class PostgresService extends SQLService {
363
471
  }
364
472
  }
365
473
 
366
- async tenant({ database, tenant }) {
474
+ async tenant({ database, tenant }, clean = false) {
367
475
  const creds = {
368
476
  database: database,
369
477
  usergroup: `${database}_USERS`,
@@ -373,18 +481,20 @@ class PostgresService extends SQLService {
373
481
  creds.password = creds.user
374
482
 
375
483
  try {
376
- await this.tx(async tx => {
377
- // await tx.run(`DROP USER IF EXISTS "${creds.user}"`)
378
- await tx
379
- .run(`CREATE USER "${creds.user}" IN GROUP "${creds.usergroup}" PASSWORD '${creds.password}'`)
380
- .catch(e => {
381
- if (e.code === '42710') return
382
- throw e
383
- })
384
- })
385
- await this.tx(async tx => {
386
- await tx.run(`GRANT CREATE, CONNECT ON DATABASE "${creds.database}" TO "${creds.user}";`)
387
- })
484
+ if (!clean) {
485
+ await this.tx(async tx => {
486
+ // await tx.run(`DROP USER IF EXISTS "${creds.user}"`)
487
+ await tx
488
+ .run(`CREATE USER "${creds.user}" IN GROUP "${creds.usergroup}" PASSWORD '${creds.password}'`)
489
+ .catch(e => {
490
+ if (e.code === '42710') return
491
+ throw e
492
+ })
493
+ })
494
+ await this.tx(async tx => {
495
+ await tx.run(`GRANT CREATE, CONNECT ON DATABASE "${creds.database}" TO "${creds.user}";`)
496
+ })
497
+ }
388
498
 
389
499
  // Update credentials to new Schema owner
390
500
  await this.disconnect()
@@ -393,7 +503,7 @@ class PostgresService extends SQLService {
393
503
  // Create new schema using schema owner
394
504
  await this.tx(async tx => {
395
505
  await tx.run(`DROP SCHEMA IF EXISTS "${creds.schema}" CASCADE`)
396
- await tx.run(`CREATE SCHEMA "${creds.schema}" AUTHORIZATION "${creds.user}"`).catch(() => {})
506
+ if (!clean) await tx.run(`CREATE SCHEMA "${creds.schema}" AUTHORIZATION "${creds.user}"`).catch(() => {})
397
507
  })
398
508
  } finally {
399
509
  await this.disconnect()
@@ -401,4 +511,204 @@ class PostgresService extends SQLService {
401
511
  }
402
512
  }
403
513
 
514
+ class QueryStream extends Query {
515
+ constructor(config, one) {
516
+ // REVISIT: currently when setting the row chunk size
517
+ // it results in an inconsistent connection state
518
+ // if (!one) config.rows = 1000
519
+ super(config)
520
+
521
+ this._one = one || config.one
522
+
523
+ this.stream = new Readable({
524
+ read: this.rows
525
+ ? () => {
526
+ this.stream.pause()
527
+ // Request more rows
528
+ this.connection.execute({
529
+ portal: this.portal,
530
+ rows: this.rows,
531
+ })
532
+ this.connection.flush()
533
+ }
534
+ : () => {},
535
+ })
536
+ this.push = this.stream.push.bind(this.stream)
537
+
538
+ this._prom = new Promise((resolve, reject) => {
539
+ this.once('error', reject)
540
+ this.once('end', () => {
541
+ if (!this._one) this.push(this.constructor.close)
542
+ this.push(null)
543
+ if (this.stream.isPaused()) this.stream.resume()
544
+ resolve(null)
545
+ })
546
+ this.once('row', row => {
547
+ if (row == null) return resolve(null)
548
+ resolve(this.stream)
549
+ })
550
+ })
551
+ }
552
+
553
+ static sep = Buffer.from(',')
554
+ static open = Buffer.from('[')
555
+ static close = Buffer.from(']')
556
+
557
+ // Trigger query initialization
558
+ _getRows(connection) {
559
+ this.connection = connection
560
+ connection.execute({
561
+ portal: this.portal,
562
+ rows: this.rows ? 1 : undefined,
563
+ })
564
+ if (this.rows) {
565
+ connection.flush()
566
+ } else {
567
+ connection.sync()
568
+ }
569
+ }
570
+
571
+ // Delay requesting more rows until next is called
572
+ handlePortalSuspended() {
573
+ this.stream.resume()
574
+ }
575
+
576
+ // Provides metadata information from the database
577
+ handleRowDescription(msg) {
578
+ // Use default parser for binary results
579
+ if (msg.fields.length === 1 && msg.fields[0].dataTypeID === 17) {
580
+ this.handleDataRow = this.handleBinaryRow
581
+ } else {
582
+ this.handleDataRow = msg => {
583
+ const val = msg.fields[0]
584
+ if (!this._one && val !== null) this.push(this.constructor.open)
585
+ this.emit('row', val)
586
+ this.push(val)
587
+ delete this.handleDataRow
588
+ }
589
+ }
590
+ return super.handleRowDescription(msg)
591
+ }
592
+
593
+ // Called when a new row is received
594
+ handleDataRow(msg) {
595
+ this.push(this.constructor.sep)
596
+ this.push(msg.fields[0])
597
+ }
598
+
599
+ // Called when a new binary row is received
600
+ handleBinaryRow(msg) {
601
+ const val = msg.fields[0] === null ? null : this._result._parsers[0](msg.fields[0])
602
+ this.push(val)
603
+ this.emit('row', val)
604
+ }
605
+
606
+ then(resolve, reject) {
607
+ return this._prom.then(resolve, reject)
608
+ }
609
+ }
610
+
611
+ class ParameterStream extends Writable {
612
+ constructor(queryName, id) {
613
+ super({})
614
+ this.queryName = queryName
615
+ this.id = id
616
+ this.text = `COPY "$$PARAMETER_BUFFER$$"(param,name,id) FROM STDIN DELIMITER ',' QUOTE '${this.constructor.sep}' CSV`
617
+ this.lengthBuffer = Buffer.from([0x64, 0, 0, 0, 0])
618
+
619
+ // Flush quote character before input stream
620
+ this.flushChunk = chunk => {
621
+ delete this.flushChunk
622
+
623
+ this.lengthBuffer.writeUInt32BE(chunk.length + 5, 1)
624
+ this.connection.stream.write(this.lengthBuffer)
625
+ this.connection.stream.write(Buffer.from(this.constructor.sep))
626
+ return this.connection.stream.write(chunk)
627
+ }
628
+ }
629
+
630
+ static sep = String.fromCharCode(31) // Separator One
631
+ static done = Buffer.from([0x63, 0, 0, 0, 4])
632
+
633
+ then(resolve, reject) {
634
+ this.on('error', reject)
635
+ this.on('finish', resolve)
636
+ }
637
+
638
+ /**
639
+ * Indicates that the query was started by the connection
640
+ * @param {Object} connection
641
+ */
642
+ submit(connection) {
643
+ this.connection = connection
644
+ // Initialize query to be executed
645
+ connection.query(this.text)
646
+ }
647
+
648
+ // Used by the client to handle timeouts
649
+ callback() {}
650
+
651
+ _write(chunk, enc, cb) {
652
+ return this.flush(chunk, cb)
653
+ }
654
+
655
+ _construct(cb) {
656
+ this.handleCopyInResponse = () => cb()
657
+ }
658
+
659
+ _destroy(err, cb) {
660
+ this.handleError = () => {
661
+ this.callback()
662
+ this.connection = null
663
+ cb(err)
664
+ }
665
+ this.connection.sendCopyFail(err ? err.message : 'ParameterStream early destroy')
666
+ }
667
+
668
+ _final(cb) {
669
+ const sep = this.constructor.sep
670
+ this.flush(Buffer.from(`${sep},${this.queryName},${this.id}`), err => {
671
+ if (err) return cb(err)
672
+ this._finish = () => {
673
+ this.emit('finish')
674
+ cb()
675
+ }
676
+ this._destroy = (err, cb) => cb(err)
677
+ this.connection.stream.write(this.constructor.done)
678
+ })
679
+ }
680
+
681
+ flush(chunk, callback) {
682
+ if (this.flushChunk(chunk)) {
683
+ return callback()
684
+ }
685
+ this.connection.stream.once('drain', callback)
686
+ }
687
+
688
+ flushChunk(chunk) {
689
+ this.lengthBuffer.writeUInt32BE(chunk.length + 4, 1)
690
+ this.connection.stream.write(this.lengthBuffer)
691
+ return this.connection.stream.write(chunk)
692
+ }
693
+
694
+ handleError(e) {
695
+ this.callback()
696
+ this.emit('error', e)
697
+ this.connection = null
698
+ }
699
+
700
+ handleCommandComplete(msg) {
701
+ const match = /COPY (\d+)/.exec((msg || {}).text)
702
+ if (match) {
703
+ this.rowCount = parseInt(match[1], 10)
704
+ }
705
+ }
706
+
707
+ handleReadyForQuery() {
708
+ this.callback()
709
+ this._finish()
710
+ this.connection = null
711
+ }
712
+ }
713
+
404
714
  module.exports = PostgresService
@@ -1,4 +1,102 @@
1
1
  {
2
+ "ALL": 1,
3
+ "ANALYSE": 1,
4
+ "ANALYZE": 1,
5
+ "AND": 1,
6
+ "ANY": 1,
7
+ "ARRAY": 1,
8
+ "AS": 1,
9
+ "ASC": 1,
10
+ "ASYMMETRIC": 1,
11
+ "AUTHORIZATION": 1,
2
12
  "BINARY": 1,
3
- "ARRAY": 1
13
+ "BOTH": 1,
14
+ "CASE": 1,
15
+ "CAST": 1,
16
+ "CHECK": 1,
17
+ "COLLATE": 1,
18
+ "COLLATION": 1,
19
+ "COLUMN": 1,
20
+ "CONCURRENTLY": 1,
21
+ "CONSTRAINT": 1,
22
+ "CREATE": 1,
23
+ "CROSS": 1,
24
+ "CURRENT_CATALOG": 1,
25
+ "CURRENT_DATE": 1,
26
+ "CURRENT_ROLE": 1,
27
+ "CURRENT_SCHEMA": 1,
28
+ "CURRENT_TIME": 1,
29
+ "CURRENT_TIMESTAMP": 1,
30
+ "CURRENT_USER": 1,
31
+ "DEFAULT": 1,
32
+ "DEFERRABLE": 1,
33
+ "DESC": 1,
34
+ "DISTINCT": 1,
35
+ "DO": 1,
36
+ "ELSE": 1,
37
+ "END": 1,
38
+ "EXCEPT": 1,
39
+ "FALSE": 1,
40
+ "FETCH": 1,
41
+ "FOR": 1,
42
+ "FOREIGN": 1,
43
+ "FREEZE": 1,
44
+ "FROM": 1,
45
+ "FULL": 1,
46
+ "GRANT": 1,
47
+ "GROUP": 1,
48
+ "HAVING": 1,
49
+ "ILIKE": 1,
50
+ "IN": 1,
51
+ "INITIALLY": 1,
52
+ "INNER": 1,
53
+ "INTERSECT": 1,
54
+ "INTO": 1,
55
+ "IS": 1,
56
+ "ISNULL": 1,
57
+ "JOIN": 1,
58
+ "LATERAL": 1,
59
+ "LEADING": 1,
60
+ "LEFT": 1,
61
+ "LIKE": 1,
62
+ "LIMIT": 1,
63
+ "LOCALTIME": 1,
64
+ "LOCALTIMESTAMP": 1,
65
+ "NATURAL": 1,
66
+ "NOT": 1,
67
+ "NOTNULL": 1,
68
+ "NULL": 1,
69
+ "OFFSET": 1,
70
+ "ON": 1,
71
+ "ONLY": 1,
72
+ "OR": 1,
73
+ "ORDER": 1,
74
+ "OUTER": 1,
75
+ "OVERLAPS": 1,
76
+ "PLACING": 1,
77
+ "PRIMARY": 1,
78
+ "REFERENCES": 1,
79
+ "RETURNING": 1,
80
+ "RIGHT": 1,
81
+ "SELECT": 1,
82
+ "SESSION_USER": 1,
83
+ "SIMILAR": 1,
84
+ "SOME": 1,
85
+ "SYMMETRIC": 1,
86
+ "TABLE": 1,
87
+ "TABLESAMPLE": 1,
88
+ "THEN": 1,
89
+ "TO": 1,
90
+ "TRAILING": 1,
91
+ "TRUE": 1,
92
+ "UNION": 1,
93
+ "UNIQUE": 1,
94
+ "USER": 1,
95
+ "USING": 1,
96
+ "VARIADIC": 1,
97
+ "VERBOSE": 1,
98
+ "WHEN": 1,
99
+ "WHERE": 1,
100
+ "WINDOW": 1,
101
+ "WITH": 1
4
102
  }
package/lib/func.js CHANGED
@@ -1,4 +1,11 @@
1
+ const session = require('./session.json')
2
+
1
3
  const StandardFunctions = {
4
+ session_context: x => {
5
+ let sql = `current_setting('${ session[x.val] || x.val }')`
6
+ if (x.val === '$now') sql += '::timestamp'
7
+ return sql
8
+ },
2
9
  countdistinct: x => `count(distinct ${x || '*'})`,
3
10
  contains: (...args) => `(coalesce(strpos(${args}),0) > 0)`,
4
11
  indexof: (x, y) => `strpos(${x},${y}) - 1`, // sqlite instr is 1 indexed
@@ -0,0 +1,7 @@
1
+ {
2
+ "$user.id": "cap.applicationuser",
3
+ "$user.locale": "cap.locale",
4
+ "$now": "cap.now",
5
+ "$valid.from": "cap.valid_from",
6
+ "$valid.to": "cap.valid_to"
7
+ }
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@cap-js/postgres",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "CDS database service for Postgres",
5
- "homepage": "https://cap.cloud.sap/",
5
+ "homepage": "https://github.com/cap-js/cds-dbs/tree/main/postgres#cds-database-service-for-postgres",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/cap-js/cds-dbs"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/cap-js/cds-dbs/issues"
12
+ },
6
13
  "keywords": [
7
14
  "CAP",
8
15
  "CDS",
@@ -20,11 +27,11 @@
20
27
  "npm": ">=8"
21
28
  },
22
29
  "scripts": {
23
- "setup": "docker-compose -f pg-stack.yml up -d",
24
- "test": "npm run setup && jest --silent"
30
+ "test": "npm start && jest --silent",
31
+ "start": "docker-compose -f pg-stack.yml up -d"
25
32
  },
26
33
  "dependencies": {
27
- "@cap-js/db-service": "^1.0.1",
34
+ "@cap-js/db-service": "^1.2.0",
28
35
  "pg": "^8"
29
36
  },
30
37
  "peerDependencies": {
@@ -53,7 +60,8 @@
53
60
  "dialect": "postgres",
54
61
  "vcap": {
55
62
  "label": "postgresql-db"
56
- }
63
+ },
64
+ "schema_evolution": "auto"
57
65
  }
58
66
  },
59
67
  "db": "sql"