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