@pbvision/firestore-orm 0.0.2
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/LICENSE +204 -0
- package/README.md +860 -0
- package/environment.js +2 -0
- package/package.json +81 -0
- package/requirements.txt +1 -0
- package/scripts/ci/lint-format-js.sh +13 -0
- package/scripts/ci/markdown-link.sh +8 -0
- package/scripts/start-local-db.sh +2 -0
- package/src/async-emitter.js +50 -0
- package/src/context.js +677 -0
- package/src/data.js +24 -0
- package/src/db-error.js +12 -0
- package/src/default-db.js +21 -0
- package/src/errors.js +149 -0
- package/src/fields.js +526 -0
- package/src/firestore.js +75 -0
- package/src/key.js +66 -0
- package/src/models.js +542 -0
- package/src/utils.js +75 -0
package/src/firestore.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const S = require('@pbvision/schema')
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
getWithArgs,
|
|
5
|
+
Context
|
|
6
|
+
} = require('./context')
|
|
7
|
+
const {
|
|
8
|
+
DeletedTwiceError,
|
|
9
|
+
InvalidFieldError,
|
|
10
|
+
InvalidOptionsError,
|
|
11
|
+
InvalidParameterError,
|
|
12
|
+
ModelAlreadyExistsError,
|
|
13
|
+
ModelTrackedTwiceError,
|
|
14
|
+
TransactionFailedError,
|
|
15
|
+
WriteAttemptedInReadOnlyTxError
|
|
16
|
+
} = require('./errors')
|
|
17
|
+
const {
|
|
18
|
+
__Field,
|
|
19
|
+
ArrayField,
|
|
20
|
+
BooleanField,
|
|
21
|
+
NumberField,
|
|
22
|
+
ObjectField,
|
|
23
|
+
StringField
|
|
24
|
+
} = require('./fields')
|
|
25
|
+
const { Key, UniqueKeyList } = require('./key')
|
|
26
|
+
const { Model } = require('./models')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @module firestore
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Setup the Firestore library before returning symbols clients can use.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} [firestoreDB] client to interact with db docs; from
|
|
36
|
+
* firebase/app::initializeApp
|
|
37
|
+
* @returns {Object} Symbols that clients of this library can use.
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
function setup (firestoreDB) {
|
|
41
|
+
Key.firestoreDB = firestoreDB
|
|
42
|
+
const toExport = {
|
|
43
|
+
S,
|
|
44
|
+
Model,
|
|
45
|
+
UniqueKeyList,
|
|
46
|
+
Context,
|
|
47
|
+
firestoreDB,
|
|
48
|
+
|
|
49
|
+
// Errors
|
|
50
|
+
DeletedTwiceError,
|
|
51
|
+
InvalidFieldError,
|
|
52
|
+
InvalidOptionsError,
|
|
53
|
+
InvalidParameterError,
|
|
54
|
+
ModelTrackedTwiceError,
|
|
55
|
+
ModelAlreadyExistsError,
|
|
56
|
+
TransactionFailedError,
|
|
57
|
+
WriteAttemptedInReadOnlyTxError
|
|
58
|
+
}
|
|
59
|
+
if (Number(process.env.INDEBUGGER)) {
|
|
60
|
+
toExport.__private = {
|
|
61
|
+
__Field,
|
|
62
|
+
fields: [
|
|
63
|
+
ArrayField,
|
|
64
|
+
BooleanField,
|
|
65
|
+
NumberField,
|
|
66
|
+
ObjectField,
|
|
67
|
+
StringField
|
|
68
|
+
],
|
|
69
|
+
getWithArgs
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return toExport
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = setup
|
package/src/key.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key uniquely identifies a model.
|
|
3
|
+
*/
|
|
4
|
+
class Key {
|
|
5
|
+
/**
|
|
6
|
+
* @param {Model} Cls a Model class
|
|
7
|
+
* @param {Object} encodedKey encoded key
|
|
8
|
+
* @param {Object} keyComponents key component values
|
|
9
|
+
* @private
|
|
10
|
+
*/
|
|
11
|
+
constructor (Cls, encodedKey, keyComponents) {
|
|
12
|
+
this.Cls = Cls
|
|
13
|
+
this.encodedKey = encodedKey
|
|
14
|
+
this.keyComponents = keyComponents
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get docRef () {
|
|
18
|
+
const db = Key.firestoreDB
|
|
19
|
+
return db.collection(this.Cls.collectionName).doc(this.encodedKey)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An array which ensures it has no more than one copy of any key.
|
|
25
|
+
*
|
|
26
|
+
* This is useful because key equality cannot be checked with the
|
|
27
|
+
* built-in equality operator.
|
|
28
|
+
*/
|
|
29
|
+
class UniqueKeyList extends Array {
|
|
30
|
+
constructor (...keys) {
|
|
31
|
+
super(...keys)
|
|
32
|
+
const hashes = keys.map(key => this.constructor.getKeyHash(key))
|
|
33
|
+
this.__keyHashes = new Set(hashes)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static getKeyHash (key) {
|
|
37
|
+
return `${key.Cls.name}::${key.encodedKey}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
push (...keys) {
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const keyHash = this.constructor.getKeyHash(key)
|
|
43
|
+
if (!this.__keyHashes.has(keyHash)) {
|
|
44
|
+
this.__keyHashes.add(keyHash)
|
|
45
|
+
super.push(key)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
filter (...args) {
|
|
51
|
+
return Array.prototype.filter.bind(this, ...args)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
map (f) {
|
|
55
|
+
const ret = []
|
|
56
|
+
for (let i = 0; i < this.length; i++) {
|
|
57
|
+
ret.push(f(this[i]))
|
|
58
|
+
}
|
|
59
|
+
return ret
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
Key,
|
|
65
|
+
UniqueKeyList
|
|
66
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
const assert = require('assert')
|
|
2
|
+
|
|
3
|
+
const { Transaction } = require('@google-cloud/firestore')
|
|
4
|
+
const S = require('@pbvision/schema')
|
|
5
|
+
const stableStringify = require('fast-json-stable-stringify')
|
|
6
|
+
|
|
7
|
+
const { Data } = require('./data')
|
|
8
|
+
const {
|
|
9
|
+
InvalidFieldError,
|
|
10
|
+
InvalidParameterError,
|
|
11
|
+
GenericModelError
|
|
12
|
+
} = require('./errors')
|
|
13
|
+
const { __Field, SCHEMA_TYPE_TO_FIELD_CLASS_MAP } = require('./fields')
|
|
14
|
+
const { Key } = require('./key')
|
|
15
|
+
const {
|
|
16
|
+
validateValue,
|
|
17
|
+
SCHEMA_TYPE_TO_JS_TYPE_MAP
|
|
18
|
+
} = require('./utils')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The base class for modeling data.
|
|
22
|
+
*/
|
|
23
|
+
class Model {
|
|
24
|
+
/**
|
|
25
|
+
* Create a representation of a database Doc. Should only be used by the
|
|
26
|
+
* library.
|
|
27
|
+
*/
|
|
28
|
+
constructor (isNew, vals, isForUpdateAndMayBePartial = false, isSet = false) {
|
|
29
|
+
this.constructor.__doOneTimeModelPrep()
|
|
30
|
+
assert.ok(typeof isNew === 'boolean', 'isNew must be a boolean')
|
|
31
|
+
assert.ok(typeof isForUpdateAndMayBePartial === 'boolean',
|
|
32
|
+
'isForUpdateAndMayBePartial must be a boolean')
|
|
33
|
+
this.isNew = isNew
|
|
34
|
+
this.__isPartial = isForUpdateAndMayBePartial
|
|
35
|
+
this.__isSet = isSet
|
|
36
|
+
assert.ok(!isSet || !isForUpdateAndMayBePartial,
|
|
37
|
+
'may not be partial when using isSet')
|
|
38
|
+
|
|
39
|
+
// __cached_attrs has a __Field subclass object for each non-key attribute.
|
|
40
|
+
this.__cached_attrs = {}
|
|
41
|
+
|
|
42
|
+
// __cached_attrs has a __Field subclass object for each non-key attribute.
|
|
43
|
+
this.__attr_getters = {}
|
|
44
|
+
|
|
45
|
+
// pull out the Key for this doc
|
|
46
|
+
let keyComponents
|
|
47
|
+
if (vals.__id !== undefined) {
|
|
48
|
+
keyComponents = this.constructor.__decodeCompoundValue(
|
|
49
|
+
this.constructor.__keyOrder, vals.__id)
|
|
50
|
+
delete vals._id
|
|
51
|
+
Object.assign(vals, keyComponents)
|
|
52
|
+
} else {
|
|
53
|
+
this.__key = this.constructor.key(vals, true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// add user-defined fields from FIELDS & key components from KEY
|
|
57
|
+
for (const [name, opts] of Object.entries(this.constructor._attrs)) {
|
|
58
|
+
this.__addField(name, opts, vals)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
Object.seal(this)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static async register (registrar) {
|
|
65
|
+
this.__doOneTimeModelPrep()
|
|
66
|
+
await registrar.registerModel(this)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Hook for finalizing a model before writing to database
|
|
71
|
+
*/
|
|
72
|
+
async finalize () {
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
__addField (name, opts, vals) {
|
|
76
|
+
const valSpecified = Object.hasOwnProperty.call(vals, name)
|
|
77
|
+
const getCachedField = () => {
|
|
78
|
+
if (this.__cached_attrs[name]) {
|
|
79
|
+
return this.__cached_attrs[name]
|
|
80
|
+
}
|
|
81
|
+
const Cls = SCHEMA_TYPE_TO_FIELD_CLASS_MAP[opts.schema.type]
|
|
82
|
+
// can't force validation of undefined values for blind updates because
|
|
83
|
+
// they are permitted to omit fields
|
|
84
|
+
const field = new Cls({
|
|
85
|
+
name,
|
|
86
|
+
opts,
|
|
87
|
+
val: vals[name],
|
|
88
|
+
valIsFromDB: !this.isNew && !this.__isPartial,
|
|
89
|
+
valSpecified: valSpecified,
|
|
90
|
+
isForUpdate: this.__isPartial
|
|
91
|
+
})
|
|
92
|
+
Object.seal(field)
|
|
93
|
+
this.__cached_attrs[name] = field
|
|
94
|
+
return field
|
|
95
|
+
}
|
|
96
|
+
this.__attr_getters[name] = getCachedField
|
|
97
|
+
if (this.isNew || (this.__isPartial && valSpecified)) {
|
|
98
|
+
getCachedField() // create the field now to trigger validation
|
|
99
|
+
}
|
|
100
|
+
Object.defineProperty(this, name, {
|
|
101
|
+
get: () => {
|
|
102
|
+
const field = getCachedField()
|
|
103
|
+
return field.get()
|
|
104
|
+
},
|
|
105
|
+
set: (val) => {
|
|
106
|
+
const field = getCachedField()
|
|
107
|
+
field.set(val)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static __getFields () {
|
|
113
|
+
return this.FIELDS
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static __validatedSchema () {
|
|
117
|
+
if (Object.constructor.hasOwnProperty.call(this, '__CACHED_SCHEMA')) {
|
|
118
|
+
return this.__CACHED_SCHEMA
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!this.KEY) {
|
|
122
|
+
throw new InvalidFieldError('KEY', 'the partition key is required')
|
|
123
|
+
}
|
|
124
|
+
if (this.KEY.isSchema || this.KEY.schema) {
|
|
125
|
+
throw new InvalidFieldError('KEY', 'must define key component name(s)')
|
|
126
|
+
}
|
|
127
|
+
if (Object.keys(this.KEY).length === 0) {
|
|
128
|
+
throw new InvalidFieldError('KEY', '/at least one partition key field/')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// cannot use the names of non-static Model members (only need to list
|
|
132
|
+
// those that are defined by the constructor; those which are on the
|
|
133
|
+
// prototype are enforced automatically)
|
|
134
|
+
const reservedNames = new Set(['isNew'])
|
|
135
|
+
const proto = this.prototype
|
|
136
|
+
const ret = {}
|
|
137
|
+
for (const schema of [this.KEY, this.__getFields()]) {
|
|
138
|
+
for (const [key, val] of Object.entries(schema)) {
|
|
139
|
+
if (ret[key]) {
|
|
140
|
+
throw new InvalidFieldError(
|
|
141
|
+
key, 'property name cannot be used more than once')
|
|
142
|
+
}
|
|
143
|
+
if (reservedNames.has(key)) {
|
|
144
|
+
throw new InvalidFieldError(
|
|
145
|
+
key, 'field name is reserved and may not be used')
|
|
146
|
+
}
|
|
147
|
+
if (key in proto) {
|
|
148
|
+
throw new InvalidFieldError(key, 'shadows a property name')
|
|
149
|
+
}
|
|
150
|
+
ret[key] = val
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this.__CACHED_SCHEMA = S.obj(ret)
|
|
154
|
+
return this.__CACHED_SCHEMA
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
static get schema () {
|
|
158
|
+
return this.__validatedSchema()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static get __keyOrder () {
|
|
162
|
+
if (Object.constructor.hasOwnProperty.call(this, '__CACHED_KEY_ORDER')) {
|
|
163
|
+
return this.__CACHED_KEY_ORDER
|
|
164
|
+
}
|
|
165
|
+
this.__validatedSchema() // use side effect to validate schema
|
|
166
|
+
this.__CACHED_KEY_ORDER = Object.keys(this.KEY).sort()
|
|
167
|
+
return this.__CACHED_KEY_ORDER
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static __validateCollectionName () {
|
|
171
|
+
const collectionName = this.collectionName
|
|
172
|
+
try {
|
|
173
|
+
assert.ok(!collectionName.endsWith('Model'), 'not include "Model"')
|
|
174
|
+
assert.ok(!collectionName.endsWith('Table'), 'not include "Table"')
|
|
175
|
+
assert.ok(!collectionName.endsWith('Collection'), 'not include "Collection"')
|
|
176
|
+
assert.ok(collectionName.indexOf('_') < 0, 'not include underscores')
|
|
177
|
+
assert.ok(collectionName[0].match(/[A-Z]/), 'start with a capitalized letter')
|
|
178
|
+
assert.ok(collectionName.match(/[a-zA-Z0-9]*/), 'only use letters or numbers')
|
|
179
|
+
} catch (e) {
|
|
180
|
+
throw new Error(`Bad collection name "${collectionName}": it must ${e.message}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check that field names don't overlap, etc.
|
|
186
|
+
* @package
|
|
187
|
+
*/
|
|
188
|
+
static __doOneTimeModelPrep () {
|
|
189
|
+
// need to check hasOwnProperty because we don't want to access this
|
|
190
|
+
// property via inheritance (i.e., our parent may have been setup, but
|
|
191
|
+
// the subclass must do its own setup)
|
|
192
|
+
if (Object.hasOwnProperty.call(this, '__setupDone')) {
|
|
193
|
+
return // one-time setup already done
|
|
194
|
+
}
|
|
195
|
+
this.__setupDone = true
|
|
196
|
+
|
|
197
|
+
this.__validateCollectionName()
|
|
198
|
+
// _attrs maps the name of attributes that are visible to users of
|
|
199
|
+
// this model. This is the combination of attributes (keys) defined by KEY
|
|
200
|
+
// and FIELDS.
|
|
201
|
+
this._attrs = {}
|
|
202
|
+
this.__KEY_COMPONENT_NAMES = new Set()
|
|
203
|
+
const partitionKeys = new Set(this.__keyOrder)
|
|
204
|
+
for (const [fieldName, schema] of Object.entries(this.schema.objectSchemas)) {
|
|
205
|
+
const isKey = partitionKeys.has(fieldName)
|
|
206
|
+
const finalFieldOpts = __Field.__validateFieldOptions(
|
|
207
|
+
this.collectionName, isKey, fieldName, schema)
|
|
208
|
+
this._attrs[fieldName] = finalFieldOpts
|
|
209
|
+
if (isKey) {
|
|
210
|
+
this.__KEY_COMPONENT_NAMES.add(fieldName)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Defines the key. Every doc in the database is uniquely identified by its'
|
|
217
|
+
* key. The default key is a UUIDv4.
|
|
218
|
+
*
|
|
219
|
+
* A key can simply be some scalar value:
|
|
220
|
+
* static KEY = { id: S.str }
|
|
221
|
+
*
|
|
222
|
+
* A key may can be "compound key", i.e., a key with one or components, each
|
|
223
|
+
* with their own name and schema:
|
|
224
|
+
* static KEY = {
|
|
225
|
+
* email: S.str,
|
|
226
|
+
* birthYear: S.int.min(1900)
|
|
227
|
+
* }
|
|
228
|
+
*/
|
|
229
|
+
static KEY = { id: S.SCHEMAS.UUID }
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Defines the non-key fields. By default there are no fields.
|
|
233
|
+
*
|
|
234
|
+
* Properties are defined as a map from field names to Schema objects:
|
|
235
|
+
* @example
|
|
236
|
+
* static FIELDS = {
|
|
237
|
+
* someNumber: S.double,
|
|
238
|
+
* someNumberWithOptions: S.double.optional().default(0).readOnly()
|
|
239
|
+
* }
|
|
240
|
+
*/
|
|
241
|
+
static FIELDS = {}
|
|
242
|
+
|
|
243
|
+
get _id () {
|
|
244
|
+
return this.constructor.__encodeCompoundValue(
|
|
245
|
+
this.constructor.__keyOrder,
|
|
246
|
+
new Proxy(this, {
|
|
247
|
+
get: (target, prop, receiver) => {
|
|
248
|
+
return target.getField(prop).__value
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Returns the underlying __Field associated with an attribute.
|
|
256
|
+
*
|
|
257
|
+
* @param {String} name the name of a field from FIELDS
|
|
258
|
+
* @returns {BooleanField|ArrayField|ObjectField|NumberField|StringField}
|
|
259
|
+
*/
|
|
260
|
+
getField (name) {
|
|
261
|
+
assert(!name.startsWith('_'), 'may not access internal computed fields')
|
|
262
|
+
return this.__attr_getters[name]()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* This is the name of the collection this model is for. By default, the
|
|
267
|
+
* collection name is the model's class name. However, classes may choose
|
|
268
|
+
* to override this method and provide there own name (e.g., for co-existed
|
|
269
|
+
* models where multiple models have data in one collection).
|
|
270
|
+
*
|
|
271
|
+
* @type {String}
|
|
272
|
+
*/
|
|
273
|
+
static get collectionName () {
|
|
274
|
+
return this.name
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Given a mapping, split compositeKeys from other model fields. Return a
|
|
279
|
+
* 3-tuple, [encodedKey, keyComponents, modelData].
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} data data to be split
|
|
282
|
+
* @package
|
|
283
|
+
*/
|
|
284
|
+
static __splitKeysAndData (data) {
|
|
285
|
+
const keyComponents = {}
|
|
286
|
+
const modelData = {}
|
|
287
|
+
Object.keys(data).forEach(key => {
|
|
288
|
+
if (this.__KEY_COMPONENT_NAMES.has(key)) {
|
|
289
|
+
keyComponents[key] = data[key]
|
|
290
|
+
} else if (this._attrs[key]) {
|
|
291
|
+
modelData[key] = data[key]
|
|
292
|
+
} else {
|
|
293
|
+
throw new InvalidParameterError('data', 'unknown field ' + key)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
const _id = this.__encodeCompoundValue(this.__keyOrder, keyComponents)
|
|
297
|
+
return [_id, keyComponents, modelData]
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async __write (ctx) {
|
|
301
|
+
// If ctx is a Transaction object, then its mutator methods like create(),
|
|
302
|
+
// etc.return the Transaction object (for chaining). There's no promise in
|
|
303
|
+
// this case because the updates will be flushed when the transaction
|
|
304
|
+
// commits.
|
|
305
|
+
// If ctx is the Firestore object, then a promise is returned. In that case
|
|
306
|
+
// we await on it here.
|
|
307
|
+
// The return value from this function is always undefined for consistency.
|
|
308
|
+
const ret = this.__writeHelper(ctx)
|
|
309
|
+
if (ret instanceof Transaction) {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
await ret
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
__writeHelper (ctx) {
|
|
316
|
+
const docRef = this.__key.docRef
|
|
317
|
+
this.finalize()
|
|
318
|
+
const data = {}
|
|
319
|
+
for (const field of Object.values(this.__cached_attrs)) {
|
|
320
|
+
if (!field.isKey) {
|
|
321
|
+
if (field.hasChangesToCommit(true) || this.isNew) {
|
|
322
|
+
const val = field.__valueForFirestoreWrite()
|
|
323
|
+
if (val !== undefined) {
|
|
324
|
+
data[field.name] = val
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (this.isNew) {
|
|
331
|
+
// write the entire document from scratch
|
|
332
|
+
if (this.__isSet) {
|
|
333
|
+
// overwrite if it already exists (create if missing)
|
|
334
|
+
return ctx.__dbCtx.set(docRef, data, { merge: false })
|
|
335
|
+
} else {
|
|
336
|
+
// fail if it already exists
|
|
337
|
+
return ctx.__dbCtx.create(docRef, data)
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
if (!Object.keys(data).length) {
|
|
341
|
+
throw new GenericModelError(
|
|
342
|
+
'update did not provide any data to change',
|
|
343
|
+
this.constructor.collectionName, this.__key.encodedKey)
|
|
344
|
+
}
|
|
345
|
+
return ctx.__dbCtx.update(docRef, data)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Indicates if any field was mutated. New models are considered to be
|
|
351
|
+
* mutated as well.
|
|
352
|
+
* @param {Boolean} expectWrites whether the model will be updated,
|
|
353
|
+
* default is true.
|
|
354
|
+
* @type {Boolean}
|
|
355
|
+
* @package
|
|
356
|
+
*/
|
|
357
|
+
__isMutated (expectWrites = true) {
|
|
358
|
+
if (this.isNew) {
|
|
359
|
+
return true
|
|
360
|
+
}
|
|
361
|
+
for (const field of Object.values(this.__cached_attrs)) {
|
|
362
|
+
if (field.hasChangesToCommit(expectWrites)) {
|
|
363
|
+
// If any field has changes that need to be committed,
|
|
364
|
+
// it will mark the model as mutated.
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Returns the string representation for the given compound values.
|
|
373
|
+
*
|
|
374
|
+
* This method throws {@link InvalidFieldError} if the compound value does
|
|
375
|
+
* not match the required schema.
|
|
376
|
+
*
|
|
377
|
+
* @param {Array<String>} keyOrder order of keys in the string representation
|
|
378
|
+
* @param {Object} values maps component names to values; may have extra
|
|
379
|
+
* fields (they will be ignored)
|
|
380
|
+
* @package
|
|
381
|
+
*/
|
|
382
|
+
static __encodeCompoundValue (keyOrder, values) {
|
|
383
|
+
const pieces = []
|
|
384
|
+
for (let i = 0; i < keyOrder.length; i++) {
|
|
385
|
+
const fieldName = keyOrder[i]
|
|
386
|
+
const fieldOpts = this._attrs[fieldName]
|
|
387
|
+
const givenValue = values[fieldName]
|
|
388
|
+
if (givenValue === undefined) {
|
|
389
|
+
throw new InvalidFieldError(fieldName, 'must be provided')
|
|
390
|
+
}
|
|
391
|
+
const valueType = validateValue(fieldName, fieldOpts, givenValue)
|
|
392
|
+
if (valueType === String) {
|
|
393
|
+
// the '\0' character cannot be stored in string fields. If you need to
|
|
394
|
+
// store a string containing this character, then you need to store it
|
|
395
|
+
// inside of an object field, e.g.,
|
|
396
|
+
// doc.someObjField = { myString: '\0' } is okay
|
|
397
|
+
if (givenValue.indexOf('\0') !== -1) {
|
|
398
|
+
throw new InvalidFieldError(
|
|
399
|
+
fieldName, 'cannot put null bytes in strings in compound values')
|
|
400
|
+
}
|
|
401
|
+
pieces.push(givenValue)
|
|
402
|
+
} else {
|
|
403
|
+
pieces.push(stableStringify(givenValue))
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return pieces.join('\0')
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Returns the map which corresponds to the given compound value string
|
|
411
|
+
*
|
|
412
|
+
* This method throws {@link InvalidFieldError} if the decoded string does
|
|
413
|
+
* not match the required schema.
|
|
414
|
+
*
|
|
415
|
+
* @param {Array<String>} keyOrder order of keys in the string representation
|
|
416
|
+
* @param {String} strVal the string representation of a compound value
|
|
417
|
+
* @package
|
|
418
|
+
*/
|
|
419
|
+
static __decodeCompoundValue (keyOrder, val) {
|
|
420
|
+
// Assume val is otherwise a string
|
|
421
|
+
const pieces = val.split('\0')
|
|
422
|
+
if (pieces.length !== keyOrder.length) {
|
|
423
|
+
throw new InvalidFieldError(
|
|
424
|
+
'KEY', 'failed to parse key: incorrect number of components')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const compoundID = {}
|
|
428
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
429
|
+
const piece = pieces[i]
|
|
430
|
+
const fieldName = keyOrder[i]
|
|
431
|
+
const fieldOpts = this._attrs[fieldName]
|
|
432
|
+
const valueType = SCHEMA_TYPE_TO_JS_TYPE_MAP[fieldOpts.schema.type]
|
|
433
|
+
if (valueType === String) {
|
|
434
|
+
compoundID[fieldName] = piece
|
|
435
|
+
} else {
|
|
436
|
+
compoundID[fieldName] = JSON.parse(piece)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
validateValue(fieldName, fieldOpts, compoundID[fieldName])
|
|
440
|
+
}
|
|
441
|
+
return compoundID
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Returns a Key identifying a unique document in this model's DB collection.
|
|
446
|
+
* @param {*} vals map of key component names to values; if there is
|
|
447
|
+
* only one partition key field (whose type is not object), then this MAY
|
|
448
|
+
* instead be just that field's value.
|
|
449
|
+
* @returns {Key} a Key object.
|
|
450
|
+
*/
|
|
451
|
+
static key (vals, ignoreData = false) {
|
|
452
|
+
const processedVals = this.__splitKeysAndDataWithPreprocessing(vals)
|
|
453
|
+
const [encodedKey, keyComponents, data] = processedVals
|
|
454
|
+
|
|
455
|
+
// ensure that vals only contained key components (no data components)
|
|
456
|
+
const dataKeys = Object.keys(data)
|
|
457
|
+
if (dataKeys.length && !ignoreData) {
|
|
458
|
+
dataKeys.sort()
|
|
459
|
+
throw new InvalidParameterError('vals',
|
|
460
|
+
`received non-key fields: ${dataKeys.join(', ')}`)
|
|
461
|
+
}
|
|
462
|
+
return new Key(this, encodedKey, keyComponents)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Returns a Data fully describing a unique document in this model's DB collection.
|
|
467
|
+
* @param {*} vals like the argument to key() but also includes non-key data
|
|
468
|
+
* @returns {Data} a Data object for use with tx.create() or
|
|
469
|
+
* tx.get(..., { createIfMissing: true })
|
|
470
|
+
*/
|
|
471
|
+
static data (vals) {
|
|
472
|
+
return new Data(this, ...this.__splitKeysAndDataWithPreprocessing(vals))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** @package */
|
|
476
|
+
static __splitKeysAndDataWithPreprocessing (vals) {
|
|
477
|
+
// if we only have one key component, then the `_id` **MAY** just be the
|
|
478
|
+
// value rather than a map of key component names to values
|
|
479
|
+
this.__doOneTimeModelPrep()
|
|
480
|
+
const pKeyOrder = this.__keyOrder
|
|
481
|
+
if (pKeyOrder.length === 1) {
|
|
482
|
+
const pFieldName = pKeyOrder[0]
|
|
483
|
+
if (!(vals instanceof Object) || !vals[pFieldName]) {
|
|
484
|
+
vals = { [pFieldName]: vals }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!(vals instanceof Object)) {
|
|
488
|
+
throw new InvalidParameterError('values',
|
|
489
|
+
'should be an object mapping key component names to values')
|
|
490
|
+
}
|
|
491
|
+
return this.__splitKeysAndData(vals)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Returns the document path to this object.
|
|
496
|
+
*/
|
|
497
|
+
toString () {
|
|
498
|
+
return this.__key.docRef.path
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
toJSON () {
|
|
502
|
+
return this.getSnapshot()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Return snapshot of the model, all fields included.
|
|
507
|
+
* @param {Object} params
|
|
508
|
+
* @param {Boolean} params.initial Whether to return the initial state
|
|
509
|
+
* @param {Boolean} params.dbKeys Whether to return _id instead of
|
|
510
|
+
* raw key fields.
|
|
511
|
+
* @param {Boolean} params.omitKey whether to omit the key
|
|
512
|
+
*/
|
|
513
|
+
getSnapshot ({ initial = false, dbKeys = false, omitKey = false } = {}) {
|
|
514
|
+
const ret = {}
|
|
515
|
+
if (dbKeys && !omitKey) {
|
|
516
|
+
if (!initial || !this.isNew) {
|
|
517
|
+
assert.ok(typeof this._id === 'string')
|
|
518
|
+
ret._id = this._id
|
|
519
|
+
} else {
|
|
520
|
+
ret._id = undefined
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const [name, getter] of Object.entries(this.__attr_getters)) {
|
|
524
|
+
const field = getter()
|
|
525
|
+
if (field.isKey) {
|
|
526
|
+
if (dbKeys || omitKey) {
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (initial) {
|
|
531
|
+
ret[name] = field.__initialValue
|
|
532
|
+
} else {
|
|
533
|
+
ret[name] = field.__value
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return ret
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
module.exports = {
|
|
541
|
+
Model
|
|
542
|
+
}
|