model-redis 0.2.0 โ 0.3.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/README.md +290 -125
- package/index.js +3 -3
- package/package.json +19 -3
- package/src/redis_model.js +304 -228
package/README.md
CHANGED
|
@@ -1,211 +1,376 @@
|
|
|
1
1
|
# Model Redis
|
|
2
2
|
|
|
3
|
-
Simple ORM model for
|
|
4
|
-
This provides a simple ORM interface, with schema, for
|
|
3
|
+
Simple ORM model for Redis in Node.js. The only external dependency is `redis`.
|
|
4
|
+
This provides a simple ORM interface, with schema, for Redis. This is not meant
|
|
5
5
|
for large data sets and is geared more for small, internal infrastructure based
|
|
6
|
-
projects that do not require complex data
|
|
6
|
+
projects that do not require complex data models.
|
|
7
7
|
|
|
8
|
+
## Features
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
- ๐ Schema-based validation with type checking
|
|
11
|
+
- ๐ Primary key and indexed field support
|
|
12
|
+
- ๐ Model relationships (one-to-one, one-to-many)
|
|
13
|
+
- ๐ Automatic type conversion (Redis strings โ native types)
|
|
14
|
+
- ๐ก๏ธ Field privacy control (exclude sensitive data from JSON)
|
|
15
|
+
- ๐งช Fully tested with 84%+ code coverage
|
|
16
|
+
- ๐ท๏ธ Key prefixing support
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
object to the ORM table. It takes an optional connected redis client object
|
|
13
|
-
or configuration for the redis module. This will return a `Table` class we
|
|
14
|
-
can use later for our model.
|
|
18
|
+
## Installation
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
```bash
|
|
21
|
+
npm install model-redis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Getting Started
|
|
25
|
+
|
|
26
|
+
`setUpTable([object])` - *Async Function* to bind the Redis connection
|
|
27
|
+
to the ORM table. It takes an optional connected redis client object
|
|
28
|
+
or configuration for the Redis module. This will return a `Table` class we
|
|
29
|
+
can use later for our models.
|
|
30
|
+
|
|
31
|
+
It is recommended you place this in a utility or lib file within your project
|
|
17
32
|
and require it when needed.
|
|
18
33
|
|
|
19
34
|
The simplest way to use this is to pass nothing to the `setUpTable` function.
|
|
20
|
-
|
|
35
|
+
This will create a connected client to Redis using the default settings:
|
|
21
36
|
|
|
22
37
|
```javascript
|
|
23
38
|
'use strict';
|
|
24
39
|
|
|
25
|
-
const {setUpTable} = require('model-redis')
|
|
40
|
+
const {setUpTable} = require('model-redis');
|
|
26
41
|
|
|
27
|
-
const Table = setUpTable();
|
|
42
|
+
const Table = await setUpTable();
|
|
28
43
|
|
|
29
44
|
module.exports = Table;
|
|
30
45
|
```
|
|
31
46
|
|
|
32
|
-
You can also pass your own configuration options to the
|
|
47
|
+
You can also pass your own configuration options to the Redis client. See the
|
|
33
48
|
redis [client configuration guide](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md)
|
|
34
49
|
for available options:
|
|
35
50
|
|
|
36
51
|
```javascript
|
|
37
52
|
'use strict';
|
|
38
53
|
|
|
39
|
-
const {setUpTable} = require('model-redis')
|
|
54
|
+
const {setUpTable} = require('model-redis');
|
|
40
55
|
|
|
41
56
|
const conf = {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
57
|
+
socket: {
|
|
58
|
+
host: '10.10.10.10',
|
|
59
|
+
port: 7676
|
|
60
|
+
},
|
|
61
|
+
username: 'admin',
|
|
62
|
+
password: 'hunter42'
|
|
63
|
+
};
|
|
49
64
|
|
|
50
|
-
const Table = setUpTable({redisConf: conf});
|
|
65
|
+
const Table = await setUpTable({redisConf: conf});
|
|
51
66
|
|
|
52
67
|
module.exports = Table;
|
|
53
68
|
```
|
|
54
69
|
|
|
55
70
|
It can also take a Redis client object, if you would like to have more control
|
|
56
|
-
or use a custom version
|
|
71
|
+
or use a custom version of Redis:
|
|
57
72
|
|
|
58
73
|
```javascript
|
|
59
74
|
'use strict';
|
|
60
75
|
|
|
61
|
-
const {setUpTable} = require('model-redis')
|
|
62
|
-
|
|
76
|
+
const {setUpTable} = require('model-redis');
|
|
63
77
|
const {createClient} = require('redis');
|
|
78
|
+
|
|
64
79
|
const client = createClient();
|
|
65
|
-
client.connect();
|
|
80
|
+
await client.connect();
|
|
66
81
|
|
|
67
|
-
const Table = setUpTable({redisClient: client});
|
|
82
|
+
const Table = await setUpTable({redisClient: client});
|
|
68
83
|
|
|
69
84
|
module.exports = Table;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Prefix Key
|
|
88
|
+
|
|
89
|
+
At some point, the Redis package removed the option to prefix a string to the
|
|
90
|
+
keys. This functionality has been added back with this package:
|
|
70
91
|
|
|
92
|
+
```javascript
|
|
93
|
+
'use strict';
|
|
94
|
+
|
|
95
|
+
const {setUpTable} = require('model-redis');
|
|
96
|
+
|
|
97
|
+
const Table = await setUpTable({
|
|
98
|
+
prefix: 'auth_app:'
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
module.exports = Table;
|
|
71
102
|
```
|
|
72
103
|
|
|
73
|
-
Once we have
|
|
104
|
+
Once we have our table object, we can start building using the ORM!
|
|
74
105
|
|
|
75
106
|
## ORM API
|
|
76
107
|
|
|
77
108
|
The Table class implements static and bound functions to perform normal ORM
|
|
78
109
|
operations. For the rest of these examples, we will implement a simple user
|
|
79
|
-
|
|
110
|
+
backend. This will show some usage and extensibility:
|
|
80
111
|
|
|
81
|
-
```
|
|
82
|
-
const Table = require('../utils/redis_model'); // Path to where the 'model-redis module is loaded and configured
|
|
83
|
-
const {Token, InviteToken} = require('./token');
|
|
112
|
+
```javascript
|
|
113
|
+
const Table = require('../utils/redis_model'); // Path to where the 'model-redis' module is loaded and configured
|
|
84
114
|
const bcrypt = require('bcrypt'); // We will use this for passwords later
|
|
85
115
|
const saltRounds = 10;
|
|
86
116
|
|
|
87
|
-
class User extends Table{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
};
|
|
117
|
+
class User extends Table {
|
|
118
|
+
static _key = 'username';
|
|
119
|
+
static _keyMap = {
|
|
120
|
+
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
|
121
|
+
'created_on': {default: function(){return Date.now()}, type: 'number'},
|
|
122
|
+
'updated_by': {default: "__NONE__", type: 'string'},
|
|
123
|
+
'updated_on': {default: function(){return Date.now()}, type: 'number', always: true},
|
|
124
|
+
'username': {isRequired: true, type: 'string', min: 3, max: 500},
|
|
125
|
+
'password': {isRequired: true, type: 'string', min: 3, max: 500, isPrivate: true},
|
|
126
|
+
'email': {isRequired: true, type: 'string'}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
static async create(data) {
|
|
130
|
+
try {
|
|
131
|
+
data['password'] = await bcrypt.hash(data['password'], saltRounds);
|
|
132
|
+
return await super.create(data);
|
|
133
|
+
} catch(error) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async setPassword(newPassword) {
|
|
139
|
+
try {
|
|
140
|
+
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
|
141
|
+
return this.update({password: hashedPassword});
|
|
142
|
+
} catch(error) {
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static async login(data) {
|
|
148
|
+
try {
|
|
149
|
+
let user = await User.get(data.username);
|
|
150
|
+
let auth = await bcrypt.compare(data.password, user.password);
|
|
151
|
+
|
|
152
|
+
if(auth) {
|
|
153
|
+
return user;
|
|
154
|
+
} else {
|
|
155
|
+
throw new Error("LoginFailed");
|
|
156
|
+
}
|
|
157
|
+
} catch(error) {
|
|
158
|
+
throw new Error("LoginFailed");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
module.exports = {User};
|
|
135
|
-
|
|
136
164
|
```
|
|
137
165
|
|
|
138
|
-
### Table
|
|
166
|
+
### Table Schema
|
|
139
167
|
|
|
140
|
-
The table schema a required aspect of using this module. The schema is defined
|
|
141
|
-
with `_key
|
|
168
|
+
The table schema is a required aspect of using this module. The schema is defined
|
|
169
|
+
with `_key` and `_keyMap`:
|
|
142
170
|
|
|
143
171
|
* `static _key` *string* is required and is basically the primary key for this
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* `static _indexed` *array* is optional list of keys to be indexed. Indexed keys
|
|
147
|
-
can be searched by with the `list()` and `listDetial()` methods.
|
|
172
|
+
table. It MUST match one of the keys in the `_keyMap` schema
|
|
148
173
|
|
|
149
174
|
* `static _keyMap` *object* is required and defines the allowed schema for the
|
|
150
|
-
table. Validation will be enforced based on what is defined in the schema.
|
|
175
|
+
table. Validation will be enforced based on what is defined in the schema.
|
|
151
176
|
|
|
152
177
|
The `_keyMap` schema is an object where the key is the name of the field and the
|
|
153
178
|
value is an object with the options for that field:
|
|
179
|
+
|
|
154
180
|
```javascript
|
|
155
181
|
'username': {isRequired: true, type: 'string', min: 3, max: 500}
|
|
156
|
-
|
|
157
182
|
```
|
|
158
183
|
|
|
159
|
-
#### Field
|
|
160
|
-
|
|
161
|
-
* `type` *string*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
* `min` *number* Used with *string* or *number* type to define the lower limit
|
|
178
|
-
|
|
184
|
+
#### Field Options:
|
|
185
|
+
|
|
186
|
+
* `type` *string* - The native type this field will be checked for. Valid types are:
|
|
187
|
+
* `string`
|
|
188
|
+
* `number`
|
|
189
|
+
* `boolean`
|
|
190
|
+
* `object`
|
|
191
|
+
|
|
192
|
+
* `isRequired` *boolean* - If set to true, this must be set when a new
|
|
193
|
+
entry is created. This has no effect on updates.
|
|
194
|
+
|
|
195
|
+
* `default` *value or function* - If nothing is passed, this will be used.
|
|
196
|
+
If a function is placed here, it will be called and its return value used.
|
|
197
|
+
|
|
198
|
+
* `always` *boolean* - If this is set and `default` is set, then its value will
|
|
199
|
+
always be used when calling update. This is useful for setting an "updated_on"
|
|
200
|
+
field or access count.
|
|
201
|
+
|
|
202
|
+
* `min` *number* - Used with *string* or *number* type to define the lower limit
|
|
203
|
+
|
|
204
|
+
* `max` *number* - Used with *string* or *number* type to define the max limit
|
|
205
|
+
|
|
206
|
+
* `isPrivate` *boolean* - If set to true, this field will be excluded from `toJSON()` output.
|
|
207
|
+
Useful for passwords or sensitive data.
|
|
208
|
+
|
|
209
|
+
* `model` *string* - For relationships, specify the model name to link to
|
|
210
|
+
|
|
211
|
+
* `rel` *string* - Relationship type: `'one'` or `'many'`
|
|
212
|
+
|
|
213
|
+
* `localKey` *string* - For relationships, the local field to use (defaults to `_key`)
|
|
214
|
+
|
|
215
|
+
* `remoteKey` *string* - For relationships, the remote field to match against
|
|
179
216
|
|
|
180
217
|
Once we have defined a `_keyMap` schema, the table can be used.
|
|
181
218
|
|
|
182
|
-
|
|
219
|
+
## Methods
|
|
220
|
+
|
|
221
|
+
### Static Methods
|
|
183
222
|
|
|
184
223
|
Static methods are used to query data and create new entries.
|
|
185
224
|
|
|
186
|
-
* `await
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
225
|
+
* `await create(data)` - Creates and returns a new entry. The passed data object
|
|
226
|
+
will be validated and a validation error (complete with all the key errors)
|
|
227
|
+
will be thrown if validation fails. Any key passed in the data object that
|
|
228
|
+
is not in the `_keyMap` schema will be dropped.
|
|
229
|
+
|
|
230
|
+
* `await list()` - Returns a list of the primary keys in the table.
|
|
231
|
+
|
|
232
|
+
* `await listDetail([options], [queryHelper])` - Returns a list of Table instances.
|
|
233
|
+
Can optionally filter by passing an options object: `{age: 30, active: true}`
|
|
190
234
|
|
|
191
|
-
* `await
|
|
192
|
-
the table. If you pass `index_field` and `index_value`, only those matching
|
|
193
|
-
will be returned.
|
|
235
|
+
* `await findall([options])` - Alias for `listDetail()`
|
|
194
236
|
|
|
195
|
-
* `await
|
|
196
|
-
|
|
237
|
+
* `await get(pk, [queryHelper])` - Returns a Table instance for the passed primary key.
|
|
238
|
+
If none is found, a not found error is thrown.
|
|
197
239
|
|
|
198
|
-
* `await
|
|
199
|
-
found a not found error is thrown
|
|
240
|
+
* `await exists(pk)` - Returns `true` or `false` if the passed PK exists.
|
|
200
241
|
|
|
201
|
-
* `
|
|
242
|
+
* `register([Model])` - Registers a model in the global registry for relationships.
|
|
243
|
+
|
|
244
|
+
### Instance Methods
|
|
202
245
|
|
|
203
246
|
Instances of a Table have the following methods:
|
|
204
247
|
|
|
205
|
-
* `await update(data)`
|
|
206
|
-
|
|
248
|
+
* `await update(data)` - Updates the current instance with the newly passed data
|
|
249
|
+
and returns the updated instance. Data validation is also enforced.
|
|
250
|
+
|
|
251
|
+
* `await remove()` - Deletes the current Table instance and returns itself.
|
|
252
|
+
|
|
253
|
+
* `toJSON()` - Returns a plain JavaScript object representation of the instance.
|
|
254
|
+
Fields marked with `isPrivate: true` are excluded.
|
|
255
|
+
|
|
256
|
+
* `toString()` - Returns the primary key value as a string.
|
|
257
|
+
|
|
258
|
+
All of these methods are extensible so proper business logic can be implemented.
|
|
259
|
+
|
|
260
|
+
## Relationships
|
|
261
|
+
|
|
262
|
+
Model Redis supports relationships between models through the model registry system:
|
|
263
|
+
|
|
264
|
+
```javascript
|
|
265
|
+
const Table = await setUpTable();
|
|
266
|
+
|
|
267
|
+
// Define User model
|
|
268
|
+
class User extends Table {
|
|
269
|
+
static _key = 'id';
|
|
270
|
+
static _keyMap = {
|
|
271
|
+
id: {type: 'string', isRequired: true},
|
|
272
|
+
name: {type: 'string', isRequired: true},
|
|
273
|
+
posts: {model: 'Post', rel: 'many', remoteKey: 'userId', localKey: 'id'}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Define Post model
|
|
278
|
+
class Post extends Table {
|
|
279
|
+
static _key = 'id';
|
|
280
|
+
static _keyMap = {
|
|
281
|
+
id: {type: 'string', isRequired: true},
|
|
282
|
+
title: {type: 'string', isRequired: true},
|
|
283
|
+
userId: {type: 'string', isRequired: true},
|
|
284
|
+
user: {model: 'User', rel: 'one', localKey: 'userId'}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Register models
|
|
289
|
+
User.register();
|
|
290
|
+
Post.register();
|
|
291
|
+
|
|
292
|
+
// Now relationships will be loaded automatically
|
|
293
|
+
const user = await User.get('user1');
|
|
294
|
+
console.log(user.posts); // Array of Post instances
|
|
295
|
+
|
|
296
|
+
const post = await Post.get('post1');
|
|
297
|
+
console.log(post.user); // User instance
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Cycle Detection
|
|
301
|
+
|
|
302
|
+
The QueryHelper class automatically prevents infinite loops in circular relationships:
|
|
303
|
+
|
|
304
|
+
```javascript
|
|
305
|
+
// User has many Posts, Post belongs to User
|
|
306
|
+
// When loading a User, it loads Posts
|
|
307
|
+
// Each Post tries to load its User (circular)
|
|
308
|
+
// QueryHelper detects this and prevents infinite recursion
|
|
309
|
+
const user = await User.get('user1');
|
|
310
|
+
// user.posts will be loaded, but user.posts[0].user won't recurse
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Error Handling
|
|
314
|
+
|
|
315
|
+
The module provides custom error types:
|
|
316
|
+
|
|
317
|
+
* `ObjectValidateError` - Thrown when validation fails, includes array of field errors
|
|
318
|
+
* `EntryNotFound` - Thrown when trying to get a non-existent entry
|
|
319
|
+
* `EntryNameUsed` - Thrown when trying to create an entry with an existing primary key
|
|
320
|
+
|
|
321
|
+
```javascript
|
|
322
|
+
try {
|
|
323
|
+
await User.create({username: 'john'}); // Missing required 'email'
|
|
324
|
+
} catch(error) {
|
|
325
|
+
if(error.name === 'ObjectValidateError') {
|
|
326
|
+
console.log(error.message); // Array of validation errors
|
|
327
|
+
console.log(error.status); // 422
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Testing
|
|
333
|
+
|
|
334
|
+
The project includes a comprehensive test suite:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
# Run all tests
|
|
338
|
+
npm test
|
|
339
|
+
|
|
340
|
+
# Run tests in watch mode
|
|
341
|
+
npm run test:watch
|
|
342
|
+
|
|
343
|
+
# Run tests with coverage
|
|
344
|
+
npm run test:coverage
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Test Coverage
|
|
348
|
+
|
|
349
|
+
- **84.88%** overall coverage
|
|
350
|
+
- **67 tests** (66 passing, 1 skipped)
|
|
351
|
+
- Tests for validation, CRUD operations, filtering, and serialization
|
|
352
|
+
|
|
353
|
+
## Development
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
# Install dependencies
|
|
357
|
+
npm install
|
|
358
|
+
|
|
359
|
+
# Run tests
|
|
360
|
+
npm test
|
|
361
|
+
|
|
362
|
+
# Generate coverage report
|
|
363
|
+
npm run test:coverage
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## License
|
|
367
|
+
|
|
368
|
+
MIT
|
|
369
|
+
|
|
370
|
+
## Contributing
|
|
371
|
+
|
|
372
|
+
Issues and pull requests are welcome! Please see the [issues page](https://github.com/wmantly/model-redis/issues) for current bugs and feature requests.
|
|
207
373
|
|
|
208
|
-
|
|
209
|
-
count, this should be 1.
|
|
374
|
+
## Known Issues
|
|
210
375
|
|
|
211
|
-
|
|
376
|
+
- [Issue #3](https://github.com/wmantly/model-redis/issues/3) - Memory leak in relationship test suite (does not affect production usage)
|
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const table = require('./src/redis_model')
|
|
3
3
|
var client = null
|
|
4
4
|
|
|
5
|
-
function setUpTable(obj){
|
|
5
|
+
async function setUpTable(obj){
|
|
6
6
|
obj = obj || {};
|
|
7
7
|
|
|
8
8
|
if(obj.redisClient){
|
|
@@ -10,11 +10,11 @@ function setUpTable(obj){
|
|
|
10
10
|
}else{
|
|
11
11
|
const {createClient} = require('redis');
|
|
12
12
|
client = createClient(obj.redisConf || {});
|
|
13
|
-
client.connect();
|
|
13
|
+
await client.connect();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// test client connection
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
return table(client, obj.prefix);
|
|
19
19
|
}
|
|
20
20
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "model-redis",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Simple ORM model for
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Simple ORM model for Redis in Node.js",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:watch": "jest --watch",
|
|
9
|
+
"test:coverage": "jest --coverage"
|
|
10
|
+
},
|
|
11
|
+
"jest": {
|
|
12
|
+
"testEnvironment": "node",
|
|
13
|
+
"coverageDirectory": "coverage",
|
|
14
|
+
"collectCoverageFrom": [
|
|
15
|
+
"src/**/*.js",
|
|
16
|
+
"index.js"
|
|
17
|
+
],
|
|
18
|
+
"testMatch": [
|
|
19
|
+
"**/tests/**/*.test.js"
|
|
20
|
+
]
|
|
8
21
|
},
|
|
9
22
|
"repository": {
|
|
10
23
|
"type": "git",
|
|
@@ -23,5 +36,8 @@
|
|
|
23
36
|
"homepage": "https://github.com/wmantly/model-redis#readme",
|
|
24
37
|
"dependencies": {
|
|
25
38
|
"redis": "^4.6.10"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"jest": "^30.2.0"
|
|
26
42
|
}
|
|
27
43
|
}
|
package/src/redis_model.js
CHANGED
|
@@ -2,237 +2,313 @@
|
|
|
2
2
|
|
|
3
3
|
const objValidate = require('./object_validate');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
class QueryHelper{
|
|
6
|
+
history = []
|
|
7
|
+
constructor(origin){
|
|
8
|
+
this.origin = origin
|
|
9
|
+
this.history.push(origin.constructor.name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static isNotCycle(modelName, queryHelper){
|
|
13
|
+
if(!(queryHelper instanceof this)){
|
|
14
|
+
return true; // No queryHelper, can't detect cycles
|
|
15
|
+
}
|
|
16
|
+
if(queryHelper.history.includes(modelName)){
|
|
17
|
+
return false; // Cycle detected - return false to skip
|
|
18
|
+
}
|
|
19
|
+
queryHelper.history.push(modelName);
|
|
20
|
+
return true; // No cycle detected - return true to continue
|
|
21
|
+
}
|
|
22
|
+
}
|
|
6
23
|
|
|
7
24
|
function setUpTable(client, prefix=''){
|
|
8
25
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
26
|
+
function redisPrefix(key){
|
|
27
|
+
return `${prefix}${key}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class Table{
|
|
31
|
+
static errors = {
|
|
32
|
+
ObjectValidateError: objValidate.ObjectValidateError,
|
|
33
|
+
EntryNameUsed: ()=>{
|
|
34
|
+
let error = new Error('EntryNameUsed');
|
|
35
|
+
error.name = 'EntryNameUsed';
|
|
36
|
+
error.message = `${this.prototype.constructor.name}:${data[this._key]} already exists`;
|
|
37
|
+
error.keys = [{
|
|
38
|
+
key: this._key,
|
|
39
|
+
message: `${this.prototype.constructor.name}:${data[this._key]} already exists`
|
|
40
|
+
}]
|
|
41
|
+
error.status = 409;
|
|
42
|
+
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static redisClient = client;
|
|
48
|
+
|
|
49
|
+
static models = {}
|
|
50
|
+
static register = function(Model){
|
|
51
|
+
Model = Model || this;
|
|
52
|
+
this.models[Model.name] = Model;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
constructor(data){
|
|
56
|
+
for(let key in data){
|
|
57
|
+
this[key] = data[key];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static async get(index, queryHelper){
|
|
62
|
+
try{
|
|
63
|
+
if(typeof index === 'object'){
|
|
64
|
+
index = index[this._key];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let result = await client.HGETALL(
|
|
68
|
+
redisPrefix(`${this.prototype.constructor.name}_${index}`)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if(!result || !Object.keys(result).length){
|
|
72
|
+
let error = new Error('EntryNotFound');
|
|
73
|
+
error.name = 'EntryNotFound';
|
|
74
|
+
error.message = `${this.prototype.constructor.name}:${index} does not exists`;
|
|
75
|
+
error.status = 404;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Redis always returns strings, use the keyMap schema to turn them
|
|
80
|
+
// back to native values.
|
|
81
|
+
result = objValidate.parseFromString(this._keyMap, result);
|
|
82
|
+
|
|
83
|
+
let instance = new this(result);
|
|
84
|
+
await instance.buildRelations(queryHelper);
|
|
85
|
+
|
|
86
|
+
return instance;
|
|
87
|
+
}catch(error){
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async buildRelations(queryHelper){
|
|
93
|
+
// Create QueryHelper if not provided
|
|
94
|
+
if(!queryHelper){
|
|
95
|
+
queryHelper = new QueryHelper(this);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for(let [key, options] of Object.entries(this.constructor._keyMap)){
|
|
99
|
+
if(options.model){
|
|
100
|
+
let remoteModel = this.constructor.models[options.model]
|
|
101
|
+
try{
|
|
102
|
+
if(!QueryHelper.isNotCycle(remoteModel.name, queryHelper)) continue;
|
|
103
|
+
if(options.rel === 'one'){
|
|
104
|
+
this[key] = await remoteModel.get(this[key] || this[options.localKey || this.constructor._key] , queryHelper)
|
|
105
|
+
}
|
|
106
|
+
if(options.rel === 'many'){
|
|
107
|
+
this[key] = await remoteModel.listDetail({
|
|
108
|
+
[options.remoteKey]: this[options.localKey || this.constructor._key],
|
|
109
|
+
}, queryHelper)
|
|
110
|
+
|
|
111
|
+
}
|
|
112
|
+
}catch(error){
|
|
113
|
+
// Silently ignore relation loading errors (record may not exist)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static async exists(index){
|
|
120
|
+
if(typeof index === 'object'){
|
|
121
|
+
index = index[this._key];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Boolean(await client.SISMEMBER(
|
|
125
|
+
redisPrefix(this.prototype.constructor.name),
|
|
126
|
+
index
|
|
127
|
+
));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async list(){
|
|
131
|
+
// return a list of all the index keys for this table.
|
|
132
|
+
try{
|
|
133
|
+
return await client.SMEMBERS(
|
|
134
|
+
redisPrefix(this.prototype.constructor.name)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
}catch(error){
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
static async listDetail(options, queryHelper){
|
|
143
|
+
|
|
144
|
+
// Return a list of the entries as instances.
|
|
145
|
+
let out = [];
|
|
146
|
+
|
|
147
|
+
for(let entry of await this.list()){
|
|
148
|
+
let instance = await this.get(entry, arguments[arguments.length - 1]);
|
|
149
|
+
if(!options) out.push(instance);
|
|
150
|
+
let matchCount = 0;
|
|
151
|
+
for(let option in options){
|
|
152
|
+
if(instance[option] === options[option] && ++matchCount === Object.keys(options).length){
|
|
153
|
+
out.push(instance);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static findall(...args){
|
|
163
|
+
return this.listDetail(...args);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static async create(data){
|
|
167
|
+
// Add a entry to this redis table.
|
|
168
|
+
try{
|
|
169
|
+
|
|
170
|
+
// Validate the passed data by the keyMap schema.
|
|
171
|
+
data = objValidate.processKeys(this._keyMap, data);
|
|
172
|
+
|
|
173
|
+
// Do not allow the caller to overwrite an existing index key,
|
|
174
|
+
if(data[this._key] && await this.exists(data)){
|
|
175
|
+
let error = new Error('EntryNameUsed');
|
|
176
|
+
error.name = 'EntryNameUsed';
|
|
177
|
+
error.message = `${this.prototype.constructor.name}:${data[this._key]} already exists`;
|
|
178
|
+
error.keys = [{
|
|
179
|
+
key: this._key,
|
|
180
|
+
message: `${this.prototype.constructor.name}:${data[this._key]} already exists`
|
|
181
|
+
}]
|
|
182
|
+
error.status = 409;
|
|
183
|
+
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Add the key to the members for this redis table
|
|
188
|
+
await client.SADD(
|
|
189
|
+
redisPrefix(this.prototype.constructor.name),
|
|
190
|
+
data[this._key]
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Add the values for this entry.
|
|
194
|
+
for(let key of Object.keys(data)){
|
|
195
|
+
if(data[key] === undefined) continue;
|
|
196
|
+
await client.HSET(
|
|
197
|
+
redisPrefix(`${this.prototype.constructor.name}_${data[this._key]}`),
|
|
198
|
+
key,
|
|
199
|
+
objValidate.parseToString(data[key])
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// return the created redis entry as entry instance.
|
|
204
|
+
return await this.get(data[this._key]);
|
|
205
|
+
} catch(error){
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async update(data){
|
|
211
|
+
// Update an existing entry.
|
|
212
|
+
try{
|
|
213
|
+
// Validate the passed data, ignoring required fields.
|
|
214
|
+
data = objValidate.processKeys(this.constructor._keyMap, data, true);
|
|
215
|
+
|
|
216
|
+
// Check to see if entry name changed.
|
|
217
|
+
if(data[this.constructor._key] && data[this.constructor._key] !== this[this.constructor._key]){
|
|
218
|
+
// Remove the index key from the tables members list.
|
|
219
|
+
|
|
220
|
+
if(data[this.constructor._key] && await this.constructor.exists(data)){
|
|
221
|
+
let error = new Error('EntryNameUsed');
|
|
222
|
+
error.name = 'EntryNameUsed';
|
|
223
|
+
error.message = `${this.constructor.name}:${data[this.constructor._key]} already exists`;
|
|
224
|
+
error.keys = [{
|
|
225
|
+
key: this.constructor._key,
|
|
226
|
+
message: `${this.constructor.name}:${data[this.constructor._key]} already exists`
|
|
227
|
+
}]
|
|
228
|
+
error.status = 409;
|
|
229
|
+
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await client.SREM(
|
|
234
|
+
redisPrefix(this.constructor.name),
|
|
235
|
+
this[this.constructor._key]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// Add the key to the members for this redis table
|
|
239
|
+
await client.SADD(
|
|
240
|
+
redisPrefix(this.constructor.name),
|
|
241
|
+
data[this.constructor._key]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await client.RENAME(
|
|
245
|
+
redisPrefix(`${this.constructor.name}_${this[this.constructor._key]}`),
|
|
246
|
+
redisPrefix(`${this.constructor.name}_${data[this.constructor._key]}`),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
}
|
|
250
|
+
// Update what ever fields that where passed.
|
|
251
|
+
|
|
252
|
+
// Loop over the data fields and apply them to redis
|
|
253
|
+
for(let key of Object.keys(data)){
|
|
254
|
+
this[key] = data[key];
|
|
255
|
+
await client.HSET(
|
|
256
|
+
redisPrefix(`${this.constructor.name}_${this[this.constructor._key]}`),
|
|
257
|
+
key, objValidate.parseToString(data[key])
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
return this;
|
|
263
|
+
|
|
264
|
+
} catch(error){
|
|
265
|
+
// Pass any error to the calling function
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async remove(data){
|
|
271
|
+
// Remove an entry from this table.
|
|
272
|
+
|
|
273
|
+
try{
|
|
274
|
+
// Remove the index key from the tables members list.
|
|
275
|
+
await client.SREM(
|
|
276
|
+
redisPrefix(this.constructor.name),
|
|
277
|
+
this[this.constructor._key]
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Remove the entries hash values.
|
|
281
|
+
let count = await client.DEL(
|
|
282
|
+
redisPrefix(`${this.constructor.name}_${this[this.constructor._key]}`)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Return the number of removed values to the caller.
|
|
286
|
+
return this;
|
|
287
|
+
|
|
288
|
+
} catch(error) {
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
toJSON(){
|
|
294
|
+
let result = {};
|
|
295
|
+
for (const [key, value] of Object.entries(this)) {
|
|
296
|
+
if(this.constructor._keyMap[key] && this.constructor._keyMap[key].isPrivate) continue;
|
|
297
|
+
result[key] = value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
// return JSON.stringify(result);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
toString(){
|
|
306
|
+
return this[this.constructor._key];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return Table;
|
|
236
312
|
}
|
|
237
313
|
|
|
238
314
|
module.exports = setUpTable;
|