@cap-js/db-service 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.
@@ -1,143 +1,148 @@
1
+ const SessionContext = require('./session-context')
2
+ const ConnectionPool = require('./generic-pool')
1
3
  const infer = require('../infer')
2
- const cds = require('@sap/cds')
4
+ const cds = require('@sap/cds/lib')
3
5
 
4
- function Pool(factory, tenant) {
5
- const pool = createPool({ __proto__: factory, create: factory.create.bind(undefined, tenant) }, factory.options)
6
- pool._trackedConnections = []
7
- return pool
8
- }
9
- const { createPool } = require('@sap/cds-foss').pool
6
+ /** @typedef {unknown} DatabaseDriver */
10
7
 
11
8
  class DatabaseService extends cds.Service {
9
+ /**
10
+ * Dictionary of connection pools per tenant
11
+ */
12
+ pools = { _factory: this.factory }
13
+
12
14
  /**
13
15
  * Return a pool factory + options property as expected by
14
16
  * https://github.com/coopernurse/node-pool#createpool.
17
+ * @abstract
18
+ * @type {import('./factory').Factory<DatabaseDriver>}
15
19
  */
16
20
  get factory() {
17
21
  throw '2b overriden in subclass'
18
22
  }
19
- pools = { _factory: this.factory }
20
-
21
- get isMultitenant() {
22
- return 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
23
- }
24
23
 
25
24
  /**
26
25
  * Set one or more session context variables like so:
27
- * ```js
28
- * const tx = cds.db.tx()
29
- * tx.set({
30
- * '$user.name': 'Alice',
31
- * '$user.role': 'admin'
32
- * })
33
- * ```
26
+ *
27
+ * const tx = cds.db.tx()
28
+ * tx.set({ foo: 'bar' })
29
+ *
30
+ * This is used in this.begin() for standard properties
31
+ * like `$user.id` or `$user.locale`.
34
32
  */
35
33
  // eslint-disable-next-line no-unused-vars
36
34
  set(variables) {
37
35
  throw '2b overridden by subclass'
38
36
  }
39
37
 
40
- infer(q, m = this.model) {
41
- return infer(q, m)
42
- }
43
-
38
+ /**
39
+ * Acquires a pooled connection and starts a session, including setting
40
+ * session context like `$user.id` or `$user.locale`, and starting a
41
+ * transaction with `BEGIN`
42
+ * @returns this
43
+ */
44
44
  async begin() {
45
+ // We expect tx.begin() being called for an txed db service
45
46
  const ctx = this.context
46
- if (!ctx) return this.tx().begin()
47
- const tenant = this.isMultitenant && ctx.tenant
48
- const pool = (this.pools[tenant] ??= new Pool(this.pools._factory, tenant))
49
- const connections = pool._trackedConnections
50
- let dbc
51
- try {
52
- dbc = this.dbc = await pool.acquire()
53
- } catch (err) {
54
- // TODO: add acquire timeout error check
55
- err.stack += `\nActive connections:${connections.length}\n${connections.map(c => c._beginStack.stack).join('\n')}`
56
- throw err
57
- }
58
- this._beginStack = new Error('begin called from:')
59
- connections.push(this)
60
- this._release = async dbc => {
61
- await pool.release(dbc)
62
- connections.splice(connections.indexOf(this), 1)
63
- }
47
+ if (!ctx) return this.tx().begin() // REVISIT: Is this correct? When does this happen?
48
+
49
+ // REVISIT: tenant should be undefined if !this.isMultitenant
50
+ let isMultitenant = 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
51
+ let tenant = isMultitenant && ctx.tenant
52
+
53
+ // Setting this.pool as used in this.acquire() and this.release()
54
+ this.pool = this.pools[tenant] ??= new ConnectionPool(this.pools._factory, tenant)
55
+
56
+ // Acquire a pooled connection
57
+ this.dbc = await this.acquire()
58
+
59
+ // Begin a session...
64
60
  try {
65
- // Setting session context variables
66
- await this.set({
67
- get '$user.id'() {
68
- return _set(this, '$user.id', ctx.user?.id || 'anonymous')
69
- },
70
- get '$user.locale'() {
71
- return _set(this, '$user.locale', ctx.locale || cds.env.i18n.default_language)
72
- },
73
- get '$valid.from'() {
74
- return _set(this, '$valid.from', ctx._?.['VALID-FROM'] ?? ctx._?.['VALID-AT'] ?? '1970-01-01T00:00:00.000Z')
75
- },
76
- get '$valid.to'() {
77
- return _set(
78
- this,
79
- '$valid.to',
80
- ctx._?.['VALID-TO'] ?? _validTo4(ctx._?.['VALID-AT']) ?? '9999-11-11T22:22:22.000Z',
81
- )
82
- },
83
- })
84
- // Run BEGIN
61
+ await this.set(new SessionContext(ctx))
85
62
  await this.send('BEGIN')
86
63
  } catch (e) {
87
- this._release(dbc)
64
+ this.release()
88
65
  throw e
89
66
  }
90
67
  return this
91
68
  }
92
69
 
70
+ /**
71
+ * Commits a transaction and releases the connection to the pool.
72
+ */
93
73
  async commit() {
94
- const dbc = this.dbc
95
- if (!dbc) return
74
+ if (!this.dbc) return
96
75
  await this.send('COMMIT')
97
- this._release(dbc) // only release on successful commit as otherwise released on rollback
76
+ this.release() // only release on successful commit as otherwise released on rollback
98
77
  }
99
78
 
79
+ /**
80
+ * Rolls back a transaction and releases the connection to the pool.
81
+ */
100
82
  async rollback() {
101
- const dbc = this.dbc
102
- if (!dbc) return
103
- try {
104
- await this.send('ROLLBACK')
105
- } finally {
106
- this._release(dbc)
107
- }
83
+ if (!this.dbc) return
84
+ else
85
+ try {
86
+ await this.send('ROLLBACK')
87
+ } finally {
88
+ this.release()
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Acquires a connection from this.pool, stored into this.dbc
94
+ * This is for subclasses to intercept, if required.
95
+ */
96
+ async acquire() {
97
+ return await this.pool.acquire()
98
+ }
99
+
100
+ /**
101
+ * Releases own connection, i.e. tix.dbc, from this.pool
102
+ * This is for subclasses to intercept, if required.
103
+ */
104
+ async release() {
105
+ return this.pool.release(this.dbc)
108
106
  }
109
107
 
110
108
  // REVISIT: should happen automatically after a configurable time
109
+ /**
110
+ * @param {string} tenant
111
+ */
111
112
  async disconnect(tenant) {
112
113
  const pool = this.pools[tenant]
113
- if (pool) delete this.pools[tenant]
114
- else return
114
+ if (!pool) return
115
115
  await pool.drain()
116
116
  await pool.clear()
117
+ delete this.pools[tenant]
117
118
  }
118
119
 
120
+ /**
121
+ * Infers the given query with this DatabaseService instance's model.
122
+ * In general `this.model` is the same then `cds.model`
123
+ * @param {CQN} query - the query to infer
124
+ * @returns {CQN} the inferred query
125
+ */
126
+ infer(query) {
127
+ return infer(query, this.model)
128
+ }
129
+
130
+ /**
131
+ * DatabaseServices also support passing native query strings to underlying databases.
132
+ */
119
133
  run(query, data, ...etc) {
120
134
  // Allow db.run('...',1,2,3,4)
121
135
  if (data !== undefined && typeof query === 'string' && typeof data !== 'object') data = [data, ...etc]
122
136
  return super.run(query, data)
123
137
  }
124
138
 
139
+ /**
140
+ * @returns {string} A url-like string used to print log output,
141
+ * e.g., in cds.deploy()
142
+ */
125
143
  url4(/*tenant*/) {
126
- // eslint-disable-line no-unused-vars
127
- let { url } = this.options?.credentials || this.options || {}
128
- return url
144
+ return this.options.credentials?.url || this.options.url
129
145
  }
130
- getDbUrl(tenant) {
131
- return this.url4(tenant)
132
- } // REVISIT: Remove after cds v6.7
133
- }
134
-
135
- const _set = (context, variable, value) => {
136
- Object.defineProperty(context, variable, { value, configurable: true })
137
- return value
138
- }
139
- const _validTo4 = validAt => {
140
- return validAt?.replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || '')
141
146
  }
142
147
 
143
148
  DatabaseService.prototype.isDatabaseService = true
@@ -0,0 +1,5 @@
1
+ import { Factory as GenericFactory, Options } from 'generic-pool'
2
+
3
+ export interface Factory<T> extends GenericFactory<T> {
4
+ options: Options
5
+ }
@@ -0,0 +1,34 @@
1
+ const { createPool } = require('@sap/cds-foss').pool
2
+
3
+ class ConnectionPool {
4
+ constructor(factory, tenant) {
5
+ let bound_factory = { __proto__: factory, create: factory.create.bind(null, tenant) }
6
+ return _track_connections4(createPool(bound_factory, factory.options))
7
+ }
8
+ }
9
+
10
+ // REVISIT: Is that really neccessary ?!
11
+ function _track_connections4(pool) {
12
+ const { acquire, release } = pool
13
+ return Object.assign(pool, {
14
+ async acquire() {
15
+ const connections = (this._trackedConnections ??= new Set())
16
+ try {
17
+ let dbc = await acquire.call(this)
18
+ connections.add((dbc._beginStack = new Error('begin called from:')))
19
+ return dbc
20
+ } catch (err) {
21
+ // TODO: add acquire timeout error check
22
+ err.stack += `\nActive connections:${connections.size}\n${[...connections].map(e => e.stack).join('\n')}`
23
+ throw err
24
+ }
25
+ },
26
+
27
+ release(dbc) {
28
+ this._trackedConnections?.delete(dbc._beginStack)
29
+ return release.call(this, dbc)
30
+ },
31
+ })
32
+ }
33
+
34
+ module.exports = ConnectionPool
@@ -0,0 +1,32 @@
1
+ const cds = require('@sap/cds/lib')
2
+
3
+ class SessionContext {
4
+ constructor(ctx) {
5
+ Object.defineProperty(this, 'ctx', { value: ctx })
6
+ }
7
+ get '$user.id'() {
8
+ return (super['$user.id'] = this.ctx.user?.id || 'anonymous')
9
+ }
10
+ get '$user.locale'() {
11
+ return (super['$user.locale'] = this.ctx.locale || cds.env.i18n.default_language)
12
+ }
13
+ // REVISIT: should be decided in spec meeting for definitive name
14
+ get $now() {
15
+ return (super.$now = (this.ctx.timestamp || new Date()).toISOString())
16
+ }
17
+ }
18
+
19
+ class TemporalSessionContext extends SessionContext {
20
+ get '$valid.from'() {
21
+ return (super['$valid.from'] = this.ctx._?.['VALID-FROM'] ?? this.ctx._?.['VALID-AT'] ?? new Date().toISOString())
22
+ }
23
+ get '$valid.to'() {
24
+ return (super['$valid.to'] =
25
+ this.ctx._?.['VALID-TO'] ??
26
+ this.ctx._?.['VALID-AT']?.replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || '') ??
27
+ new Date().toISOString().replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || ''))
28
+ }
29
+ }
30
+
31
+ // REVISIT: only set temporal context if required!
32
+ module.exports = TemporalSessionContext
@@ -0,0 +1,24 @@
1
+ declare function ConverterFunction(expression: string): string
2
+ export type Converter = typeof ConverterFunction
3
+
4
+ export type Converters = {
5
+ UUID: Converter
6
+ String: Converter
7
+ LargeString: Converter
8
+ Binary: Converter
9
+ LargeBinary: Converter
10
+ Boolean: Converter
11
+ Integer: Converter
12
+ UInt8: Converter
13
+ Int16: Converter
14
+ Int32: Converter
15
+ Int64: Converter
16
+ Float: Converter
17
+ Double: Converter
18
+ Decimal: Converter
19
+ DecimalFloat: Converter
20
+ Date: Converter
21
+ Time: Converter
22
+ DateTime: Converter
23
+ Timestamp: Converter
24
+ }
@@ -2,49 +2,202 @@ const StandardFunctions = {
2
2
  // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
3
3
 
4
4
  // String and Collection Functions
5
- // length : (x) => `length(${x})`,
5
+ /**
6
+ * Generates SQL statement that produces the length of a given string
7
+ * @param {string} x
8
+ * @returns {string}
9
+ */
10
+ length: x => `length(${x})`,
11
+ /**
12
+ * Generates SQL statement that produces the average of a given expression
13
+ * @param {string} x
14
+ * @returns {string}
15
+ */
6
16
  average: x => `avg(${x})`,
17
+ /**
18
+ * Generates SQL statement that produces a boolean value indicating whether the search term is contained in the given columns
19
+ * @param {string} ref
20
+ * @param {string} arg
21
+ * @returns {string}
22
+ */
7
23
  search: function (ref, arg) {
8
24
  if (!('val' in arg)) throw `SQLite only supports single value arguments for $search`
9
25
  const refs = ref.list || [ref],
10
26
  { toString } = ref
11
27
  return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
12
28
  },
13
- concat: (...args) => args.join('||'),
29
+ /**
30
+ * Generates SQL statement that produces a string with all provided strings concatenated
31
+ * @param {...string} args
32
+ * @returns {string}
33
+ */
34
+ concat: (...args) => args.map(a => a.xpr ? `(${a})` : a).join(' || '),
35
+
36
+ /**
37
+ * Generates SQL statement that produces a boolean value indicating whether the first string contains the second string
38
+ * @param {...string} args
39
+ * @returns {string}
40
+ */
14
41
  contains: (...args) => `ifnull(instr(${args}),0)`,
42
+ /**
43
+ * Generates SQL statement that produces the number of elements in a given collection
44
+ * @param {string} x
45
+ * @returns {string}
46
+ */
15
47
  count: x => `count(${x || '*'})`,
48
+ /**
49
+ * Generates SQL statement that produces the number of distinct values of a given expression
50
+ * @param {string} x
51
+ * @returns {string}
52
+ */
16
53
  countdistinct: x => `count(distinct ${x || '*'})`,
54
+ /**
55
+ * Generates SQL statement that produces the index of the first occurrence of the second string in the first string
56
+ * @param {string} x
57
+ * @param {string} y
58
+ * @returns {string}
59
+ */
17
60
  indexof: (x, y) => `instr(${x},${y}) - 1`, // sqlite instr is 1 indexed
61
+ /**
62
+ * Generates SQL statement that produces a boolean value indicating whether the first string starts with the second string
63
+ * @param {string} x
64
+ * @param {string} y
65
+ * @returns {string}
66
+ */
18
67
  startswith: (x, y) => `instr(${x},${y}) = 1`, // sqlite instr is 1 indexed
19
68
  // takes the end of the string of the size of the target and compares it with the target
69
+ /**
70
+ * Generates SQL statement that produces a boolean value indicating whether the first string ends with the second string
71
+ * @param {string} x
72
+ * @param {string} y
73
+ * @returns {string}
74
+ */
20
75
  endswith: (x, y) => `substr(${x}, length(${x}) + 1 - length(${y})) = ${y}`,
76
+ /**
77
+ * Generates SQL statement that produces the substring of a given string
78
+ * @example
79
+ * // returns 'bc'
80
+ * {func:'substring',args:[{val:'abc'},{val:1}]}
81
+ * @example
82
+ * // returns 'b'
83
+ * {func:'substring',args:[{val:'abc'},{val:1},{val:1}]}
84
+ * @param {string} x
85
+ * @param {string} y
86
+ * @param {string} z
87
+ * @returns {string}
88
+ */
21
89
  substring: (x, y, z) =>
22
90
  z
23
91
  ? `substr( ${x}, case when ${y} < 0 then length(${x}) + ${y} + 1 else ${y} + 1 end, ${z} )`
24
92
  : `substr( ${x}, case when ${y} < 0 then length(${x}) + ${y} + 1 else ${y} + 1 end )`,
25
93
 
26
94
  // String Functions
95
+ /**
96
+ * Generates SQL statement that matches the given string against a regular expression
97
+ * @param {string} x
98
+ * @param {string} y
99
+ * @returns {string}
100
+ */
27
101
  matchesPattern: (x, y) => `${x} regexp ${y})`,
102
+ /**
103
+ * Generates SQL statement that produces the lower case value of a given string
104
+ * @param {string} x
105
+ * @returns {string}
106
+ */
28
107
  tolower: x => `lower(${x})`,
108
+ /**
109
+ * Generates SQL statement that produces the upper case value of a given string
110
+ * @param {string} x
111
+ * @returns {string}
112
+ */
29
113
  toupper: x => `upper(${x})`,
30
- // trim : (x) => `trim(${x})`,
114
+ /**
115
+ * Generates SQL statement that produces the trimmed value of a given string
116
+ * @param {string} x
117
+ * @returns {string}
118
+ */
119
+ trim: x => `trim(${x})`,
31
120
 
32
121
  // Arithmetic Functions
122
+ /**
123
+ * Generates SQL statement that produces the rounded up value of a given number
124
+ * @param {string} x
125
+ * @returns {string}
126
+ */
33
127
  ceiling: x => `ceil(${x})`,
34
- // floor : (x) => `floor(${x})`,
35
- // round : (x) => `round(${x})`,
128
+ /**
129
+ * Generates SQL statement that produces the rounded down value of a given number
130
+ * @param {string} x
131
+ * @returns {string}
132
+ */
133
+ floor: x => `floor(${x})`,
134
+ /**
135
+ * Generates SQL statement that produces the rounded value of a given number
136
+ * @param {string} x
137
+ * @param {string} p precision
138
+ * @returns {string}
139
+ */
140
+ round: (x, p) => `round(${x}${p ? `,${p}` : ''})`,
36
141
 
37
142
  // Date and Time Functions
143
+
144
+ current_date: p => p ? `current_date(${p})`: 'current_date',
145
+ current_time: p => p ? `current_time(${p})`: 'current_time',
146
+ current_timestamp: p => p ? `current_timestamp(${p})`: 'current_timestamp',
147
+
148
+ /**
149
+ * Generates SQL statement that produces the year of a given timestamp
150
+ * @param {string} x
151
+ * @returns {string}
152
+ */
38
153
  year: x => `cast( strftime('%Y',${x}) as Integer )`,
154
+ /**
155
+ * Generates SQL statement that produces the month of a given timestamp
156
+ * @param {string} x
157
+ * @returns {string}
158
+ */
39
159
  month: x => `cast( strftime('%m',${x}) as Integer )`,
160
+ /**
161
+ * Generates SQL statement that produces the day of a given timestamp
162
+ * @param {string} x
163
+ * @returns {string}
164
+ */
40
165
  day: x => `cast( strftime('%d',${x}) as Integer )`,
166
+ /**
167
+ * Generates SQL statement that produces the hours of a given timestamp
168
+ * @param {string} x
169
+ * @returns {string}
170
+ */
41
171
  hour: x => `cast( strftime('%H',${x}) as Integer )`,
172
+ /**
173
+ * Generates SQL statement that produces the minutes of a given timestamp
174
+ * @param {string} x
175
+ * @returns {string}
176
+ */
42
177
  minute: x => `cast( strftime('%M',${x}) as Integer )`,
178
+ /**
179
+ * Generates SQL statement that produces the seconds of a given timestamp
180
+ * @param {string} x
181
+ * @returns {string}
182
+ */
43
183
  second: x => `cast( strftime('%S',${x}) as Integer )`,
44
184
 
185
+ /**
186
+ * Generates SQL statement that produces the fractional seconds of a given timestamp
187
+ * @param {string} x
188
+ * @returns {string}
189
+ */
45
190
  fractionalseconds: x => `cast( strftime('%f0000',${x}) as Integer )`,
46
191
 
192
+ /**
193
+ * maximum date time value
194
+ * @returns {string}
195
+ */
47
196
  maxdatetime: () => '9999-12-31 23:59:59.999',
197
+ /**
198
+ * minimum date time value
199
+ * @returns {string}
200
+ */
48
201
  mindatetime: () => '0001-01-01 00:00:00.000',
49
202
 
50
203
  // odata spec defines the date time offset type as a normal ISO time stamp
@@ -52,6 +205,11 @@ const StandardFunctions = {
52
205
  // sqlite understands this so by splitting the timezone from the actual date
53
206
  // prefixing it with 1970 it allows sqlite to give back the number of seconds
54
207
  // which can be divided by 60 back to minutes
208
+ /**
209
+ * Generates SQL statement that produces the offset in minutes of a given date time offset string
210
+ * @param {string} x
211
+ * @returns {string}
212
+ */
55
213
  totaloffsetminutes: x => `case
56
214
  when substr(${x}, length(${x})) = 'z' then 0
57
215
  else strftime('%s', '1970-01-01T00:00:00' || substr(${x}, length(${x}) - 5)) / 60
@@ -65,6 +223,11 @@ const StandardFunctions = {
65
223
  // the number of seconds in a day
66
224
  // As sqlite is most accurate with juliandays it is better to do then then using actual second function
67
225
  // while the odata specification states that the seconds has to be fractional which only julianday allows
226
+ /**
227
+ * Generates SQL statement that produces an OData compliant duration string like: P12DT23H59M59.999999999999S
228
+ * @param {string} x
229
+ * @returns {string}
230
+ */
68
231
  totalseconds: x => `(
69
232
  (
70
233
  (
@@ -84,18 +247,43 @@ const StandardFunctions = {
84
247
  )
85
248
  ) * 86400
86
249
  )`,
250
+
251
+ /**
252
+ * Generates SQL statement that calls the session_context function with the given parameter
253
+ * @param {string} x session variable name or SQL expression
254
+ * @returns {string}
255
+ */
256
+ session_context: x => `session_context('${x.val}')`,
87
257
  }
88
258
 
89
259
  const HANAFunctions = {
90
260
  // https://help.sap.com/docs/SAP_HANA_PLATFORM/4fe29514fd584807ac9f2a04f6754767/f12b86a6284c4aeeb449e57eb5dd3ebd.html
91
261
 
92
262
  // Time functions
263
+ /**
264
+ * Generates SQL statement that calculates the difference in 100nanoseconds between two timestamps
265
+ * @param {string} x left timestamp
266
+ * @param {string} y right timestamp
267
+ * @returns {string}
268
+ */
93
269
  nano100_between: (x, y) => `(julianday(${y}) - julianday(${x})) * 864000000000`,
270
+ /**
271
+ * Generates SQL statement that calculates the difference in seconds between two timestamps
272
+ * @param {string} x left timestamp
273
+ * @param {string} y right timestamp
274
+ * @returns {string}
275
+ */
94
276
  seconds_between: (x, y) => `(julianday(${y}) - julianday(${x})) * 86400`,
95
277
  // Calculates the difference in full days using julian day
96
278
  // Using the exact time of the day to determine whether 24 hours have passed or not to add the final day
97
279
  // When just comparing the julianday values with each other there are leap seconds included
98
280
  // Which on the day resolution are included as the individual days therefor ignoring them to match HANA
281
+ /**
282
+ * Generates SQL statement that calculates the difference in days between two timestamps
283
+ * @param {string} x left timestamp
284
+ * @param {string} y right timestamp
285
+ * @returns {string}
286
+ */
99
287
  days_between: (x, y) => `(
100
288
  cast ( julianday(${y}) as Integer ) - cast ( julianday(${x}) as Integer )
101
289
  ) + (
@@ -122,6 +310,12 @@ const HANAFunctions = {
122
310
 
123
311
  Also check whether the result will be positive or negative to make sure to not subtract an extra month
124
312
  */
313
+ /**
314
+ * Generates SQL statement that calculates the difference in months between two timestamps
315
+ * @param {string} x left timestamp
316
+ * @param {string} y right timestamp
317
+ * @returns {string}
318
+ */
125
319
  months_between: (x, y) => `
126
320
  (
127
321
  (
@@ -139,6 +333,12 @@ const HANAFunctions = {
139
333
  )
140
334
  )
141
335
  )`,
336
+ /**
337
+ * Generates SQL statement that calculates the difference in years between two timestamps
338
+ * @param {string} x left timestamp
339
+ * @param {string} y right timestamp
340
+ * @returns {string}
341
+ */
142
342
  years_between(x, y) {
143
343
  return `floor(${this.months_between(x, y)} / 12)`
144
344
  },