@ealforque/sequelize-field-parser 1.0.4 → 1.0.5
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 +195 -30
- package/dist/field_parser.service.d.ts +2 -1
- package/dist/field_parser.service.js +92 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,11 +9,17 @@ A TypeScript utility for Sequelize models that lets users specify fields to incl
|
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
|
-
- Parse Sequelize model fields and relationships
|
|
13
|
-
- Generate field trees for complex models
|
|
14
|
-
- Type-safe interfaces and types
|
|
15
|
-
- Easy integration with MySQL via Sequelize
|
|
16
|
-
- Test-driven development with Jest
|
|
12
|
+
- **Parse Sequelize model fields and relationships:** Easily extract and validate fields and associations from models
|
|
13
|
+
- **Generate field trees for complex models:** Build nested relationship trees for Sequelize includes
|
|
14
|
+
- **Type-safe interfaces and types:** All parsing and tree generation is type-safe
|
|
15
|
+
- **Easy integration with MySQL via Sequelize:** Works seamlessly with Sequelize ORM
|
|
16
|
+
- **Test-driven development with Jest:** Comprehensive test suite for robust behavior
|
|
17
|
+
- **Handles maximum relationship depth (default: 10):** Prevents runaway includes and logs warnings
|
|
18
|
+
- **Detects and prevents circular relationships:** Safely handles circular model associations
|
|
19
|
+
- **Handles malformed input:** Catches empty, whitespace, consecutive dots, leading/trailing dots
|
|
20
|
+
- **Deduplicates columns:** Duplicate fields in the input string will not result in duplicate entries in the `columns` array
|
|
21
|
+
- **Model requirements:** Models must define static `DEFAULT_FIELDS` and `SELECTABLE_FIELDS` properties. If these are missing, no fields will be selectable and all user-specified fields may be reported as invalid.
|
|
22
|
+
- **Input requirements:** Only valid Sequelize model classes or objects with the required static properties should be passed. Passing a non-model or incorrectly typed object may result in type errors or runtime errors.
|
|
17
23
|
|
|
18
24
|
## Installation
|
|
19
25
|
|
|
@@ -21,42 +27,201 @@ A TypeScript utility for Sequelize models that lets users specify fields to incl
|
|
|
21
27
|
npm install @ealforque/sequelize-field-parser
|
|
22
28
|
```
|
|
23
29
|
|
|
24
|
-
## Usage
|
|
30
|
+
## Usage (Correct Example)
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
```typescript
|
|
33
|
+
import FieldParserService from "./src/field_parser.service";
|
|
34
|
+
import SomeModel from "./models/SomeModel";
|
|
35
|
+
|
|
36
|
+
const parser = new FieldParserService();
|
|
37
|
+
const result = parser.parseFields("id,name,profile.email", SomeModel);
|
|
38
|
+
console.log(result);
|
|
39
|
+
/*
|
|
40
|
+
{
|
|
41
|
+
columns: ['id', 'name'],
|
|
42
|
+
relationshipTree: { profile: { email: true } },
|
|
43
|
+
invalidFields: []
|
|
44
|
+
}
|
|
45
|
+
*/
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Edge Case Handling
|
|
51
|
+
|
|
52
|
+
### Malformed Input
|
|
27
53
|
|
|
28
54
|
```typescript
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
const parser = new FieldParserService();
|
|
56
|
+
const result = parser.parseFields(" ,foo..bar,.baz,", SomeModel);
|
|
57
|
+
console.log(result); // invalidFields will include malformed entries
|
|
58
|
+
/*
|
|
59
|
+
{
|
|
60
|
+
columns: [],
|
|
61
|
+
relationshipTree: {},
|
|
62
|
+
invalidFields: ['foo..bar', '.baz']
|
|
63
|
+
}
|
|
64
|
+
*/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Missing Static Properties
|
|
31
68
|
|
|
69
|
+
```typescript
|
|
32
70
|
const parser = new FieldParserService();
|
|
71
|
+
const result = parser.parseFields("id,name", ModelWithoutStatics);
|
|
72
|
+
console.log(result); // columns will be empty, invalidFields will include all
|
|
73
|
+
/*
|
|
74
|
+
{
|
|
75
|
+
columns: [],
|
|
76
|
+
relationshipTree: {},
|
|
77
|
+
invalidFields: ['id', 'name']
|
|
78
|
+
}
|
|
79
|
+
*/
|
|
80
|
+
```
|
|
33
81
|
|
|
34
|
-
|
|
35
|
-
// api/resource?fields='status.uuid,status.name,status.category.uuid,status.category.name'
|
|
36
|
-
const queryParams =
|
|
37
|
-
"status.uuid,status.name,status.category.uuid,status.category.name";
|
|
82
|
+
### Non-Model Input
|
|
38
83
|
|
|
39
|
-
|
|
40
|
-
const
|
|
84
|
+
```typescript
|
|
85
|
+
const parser = new FieldParserService();
|
|
86
|
+
const result = parser.parseFields("id,name", {});
|
|
87
|
+
console.log(result); // columns empty, invalidFields includes all
|
|
88
|
+
/*
|
|
89
|
+
{
|
|
90
|
+
columns: [],
|
|
91
|
+
relationshipTree: {},
|
|
92
|
+
invalidFields: ['id', 'name']
|
|
93
|
+
}
|
|
94
|
+
*/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Non-Existent Associations
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
const parser = new FieldParserService();
|
|
101
|
+
const result = parser.parseFields("profile.address.zip", SomeModel);
|
|
102
|
+
console.log(result); // invalidFields includes non-existent associations
|
|
103
|
+
/*
|
|
104
|
+
{
|
|
105
|
+
columns: [],
|
|
106
|
+
relationshipTree: {},
|
|
107
|
+
invalidFields: ['profile.address.zip']
|
|
108
|
+
}
|
|
109
|
+
*/
|
|
110
|
+
```
|
|
41
111
|
|
|
42
|
-
|
|
43
|
-
const include = parser.buildSequelizeInclude(relationshipTree, Model);
|
|
112
|
+
### Deeply Nested/Circular Relationships
|
|
44
113
|
|
|
45
|
-
|
|
46
|
-
|
|
114
|
+
```typescript
|
|
115
|
+
const parser = new FieldParserService();
|
|
116
|
+
const result = parser.parseFields("a.b.c.d.e.f.g.h.i.j.k", SomeModel);
|
|
117
|
+
console.log(result); // invalidFields includes overly deep/circular fields
|
|
118
|
+
/*
|
|
119
|
+
{
|
|
120
|
+
columns: [],
|
|
121
|
+
relationshipTree: {},
|
|
122
|
+
invalidFields: ['a.b.c.d.e.f.g.h.i.j.k']
|
|
123
|
+
}
|
|
124
|
+
*/
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Duplicate Fields
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const parser = new FieldParserService();
|
|
131
|
+
const result = parser.parseFields("id,id,name,name", SomeModel);
|
|
132
|
+
console.log(result); // columns deduplicated
|
|
133
|
+
/*
|
|
134
|
+
{
|
|
135
|
+
columns: ['id', 'name'],
|
|
136
|
+
relationshipTree: {},
|
|
137
|
+
invalidFields: []
|
|
138
|
+
}
|
|
139
|
+
*/
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Relationship Leaf Attribute Filtering
|
|
143
|
+
|
|
144
|
+
If a nested relationship contains attributes not in `SELECTABLE_FIELDS`, those attributes are filtered out and a warning is logged for each ignored attribute.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const parser = new FieldParserService();
|
|
148
|
+
const tree = {
|
|
149
|
+
status: {
|
|
150
|
+
name: true,
|
|
151
|
+
invalid_field: true,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const mockModel = {
|
|
155
|
+
associations: {
|
|
156
|
+
status: {
|
|
157
|
+
target: { SELECTABLE_FIELDS: ["name"] },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const result = parser.buildSequelizeInclude(tree, mockModel);
|
|
162
|
+
console.log(result);
|
|
163
|
+
/*
|
|
164
|
+
Warning: FieldParserService: Attribute 'invalid_field' in relationship 'status' is not in SELECTABLE_FIELDS and will be ignored.
|
|
165
|
+
[
|
|
166
|
+
{
|
|
167
|
+
model: { SELECTABLE_FIELDS: ["name"] },
|
|
168
|
+
as: "status",
|
|
169
|
+
attributes: ["name"],
|
|
170
|
+
include: [],
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
*/
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Empty Relationship Attributes (No DEFAULT_FIELDS)
|
|
177
|
+
|
|
178
|
+
If a relationship is specified but no attributes are selected and the related model does not define `DEFAULT_FIELDS`, a warning is logged and the attributes array will be empty.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const parser = new FieldParserService();
|
|
182
|
+
const tree = {
|
|
183
|
+
status: {}, // No attributes selected
|
|
184
|
+
};
|
|
185
|
+
const mockModel = {
|
|
186
|
+
associations: {
|
|
187
|
+
status: {
|
|
188
|
+
target: { SELECTABLE_FIELDS: [] }, // No DEFAULT_FIELDS
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
const result = parser.buildSequelizeInclude(tree, mockModel);
|
|
193
|
+
console.log(result);
|
|
194
|
+
/*
|
|
195
|
+
Warning: FieldParserService: No attributes selected and no DEFAULT_FIELDS available for relationship 'status'. Attributes array will be empty.
|
|
47
196
|
[
|
|
48
197
|
{
|
|
49
|
-
model:
|
|
50
|
-
as:
|
|
51
|
-
attributes: [
|
|
52
|
-
include: [
|
|
53
|
-
|
|
54
|
-
model: Category,
|
|
55
|
-
as: 'category',
|
|
56
|
-
attributes: ['uuid', 'name'],
|
|
57
|
-
}
|
|
58
|
-
]
|
|
59
|
-
}
|
|
198
|
+
model: { SELECTABLE_FIELDS: [] },
|
|
199
|
+
as: "status",
|
|
200
|
+
attributes: [],
|
|
201
|
+
include: [],
|
|
202
|
+
},
|
|
60
203
|
]
|
|
61
204
|
*/
|
|
62
205
|
```
|
|
206
|
+
|
|
207
|
+
### Association Alias Mismatches
|
|
208
|
+
|
|
209
|
+
If the association alias in the model does not match the field string, the relationship will not be included and a warning is logged.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const parser = new FieldParserService();
|
|
213
|
+
const mockModel = {
|
|
214
|
+
associations: {}, // No 'profile' association
|
|
215
|
+
SELECTABLE_FIELDS: ["id", "name"],
|
|
216
|
+
};
|
|
217
|
+
const result = parser.parseFields("profile.email", mockModel);
|
|
218
|
+
console.log(result);
|
|
219
|
+
/*
|
|
220
|
+
Warning: FieldParserService: Association alias 'profile' does not exist in model 'unknown'. Field 'profile.email' will be treated as invalid.
|
|
221
|
+
{
|
|
222
|
+
columns: [],
|
|
223
|
+
relationshipTree: {},
|
|
224
|
+
invalidFields: ["profile.email"]
|
|
225
|
+
}
|
|
226
|
+
*/
|
|
227
|
+
```
|
|
@@ -7,7 +7,8 @@ declare class FieldParserService {
|
|
|
7
7
|
parseFields: <M extends Model>(fields: string, model: ModelStaticWithFields<M>) => {
|
|
8
8
|
columns: string[];
|
|
9
9
|
relationshipTree: RelationshipTree;
|
|
10
|
+
invalidFields: string[];
|
|
10
11
|
};
|
|
11
|
-
buildSequelizeInclude: <M extends Model>(tree: RelationshipTree, model: ModelStatic<M>) => IncludeOptions[];
|
|
12
|
+
buildSequelizeInclude: <M extends Model>(tree: RelationshipTree, model: ModelStatic<M>, depth?: number, visitedModels?: Set<any>) => IncludeOptions[];
|
|
12
13
|
}
|
|
13
14
|
export default FieldParserService;
|
|
@@ -6,37 +6,100 @@ class FieldParserService {
|
|
|
6
6
|
this.fields = [];
|
|
7
7
|
}
|
|
8
8
|
parseFields = (fields, model) => {
|
|
9
|
+
if (typeof model !== "object" ||
|
|
10
|
+
model === null ||
|
|
11
|
+
!("associations" in model) ||
|
|
12
|
+
typeof model.associations !== "object") {
|
|
13
|
+
console.warn("FieldParserService: Input is not a valid Sequelize model. Returning all fields as invalid.");
|
|
14
|
+
const inputFields = fields
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((f) => f.trim())
|
|
17
|
+
.filter((f) => f.length > 0);
|
|
18
|
+
return {
|
|
19
|
+
columns: [],
|
|
20
|
+
relationshipTree: {},
|
|
21
|
+
invalidFields: inputFields,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
9
24
|
this.fields = fields
|
|
10
25
|
.split(",")
|
|
11
26
|
.map((f) => f.trim())
|
|
12
27
|
.filter((f) => f.length > 0);
|
|
13
28
|
const relationshipTree = {};
|
|
14
|
-
const
|
|
15
|
-
const
|
|
29
|
+
const invalidFields = [];
|
|
30
|
+
const safeModel = model;
|
|
31
|
+
const columns = Array.isArray(safeModel.DEFAULT_FIELDS)
|
|
32
|
+
? [...safeModel.DEFAULT_FIELDS]
|
|
33
|
+
: [];
|
|
34
|
+
const selectableFields = Array.isArray(safeModel.SELECTABLE_FIELDS)
|
|
35
|
+
? safeModel.SELECTABLE_FIELDS
|
|
36
|
+
: [];
|
|
16
37
|
for (const field of this.fields) {
|
|
38
|
+
// Basic validation to catch empty fields, fields with only whitespace, and invalid dot usage
|
|
39
|
+
if (field === "" ||
|
|
40
|
+
/^\s*$/.test(field) ||
|
|
41
|
+
/\.\./.test(field) ||
|
|
42
|
+
/^\.|\.$/.test(field)) {
|
|
43
|
+
invalidFields.push(field);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
17
46
|
const segments = field.split(".");
|
|
18
47
|
if (segments.length === 1) {
|
|
19
48
|
const topField = segments[0];
|
|
20
49
|
if (selectableFields.includes(topField)) {
|
|
21
50
|
columns.push(topField);
|
|
22
51
|
}
|
|
52
|
+
else {
|
|
53
|
+
invalidFields.push(field);
|
|
54
|
+
}
|
|
23
55
|
}
|
|
24
56
|
else {
|
|
25
57
|
let relationship = relationshipTree;
|
|
58
|
+
let currentModel = model;
|
|
59
|
+
let valid = true;
|
|
26
60
|
for (let i = 0; i < segments.length; i++) {
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
61
|
+
const seg = segments[i];
|
|
62
|
+
if (i < segments.length - 1) {
|
|
63
|
+
if (!currentModel.associations || !currentModel.associations[seg]) {
|
|
64
|
+
console.warn(`FieldParserService: Association alias '${seg}' does not exist in model '${currentModel.name || "unknown"}'. Field '${field}' will be treated as invalid.`);
|
|
65
|
+
valid = false;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
currentModel = currentModel.associations[seg].target;
|
|
69
|
+
if (!relationship[seg]) {
|
|
70
|
+
relationship[seg] = {};
|
|
71
|
+
}
|
|
72
|
+
relationship = relationship[seg];
|
|
30
73
|
}
|
|
31
|
-
|
|
32
|
-
|
|
74
|
+
else {
|
|
75
|
+
if (currentModel.SELECTABLE_FIELDS &&
|
|
76
|
+
currentModel.SELECTABLE_FIELDS.includes(seg)) {
|
|
77
|
+
relationship[seg] = true;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
valid = false;
|
|
81
|
+
}
|
|
33
82
|
}
|
|
34
83
|
}
|
|
84
|
+
if (!valid) {
|
|
85
|
+
invalidFields.push(field);
|
|
86
|
+
}
|
|
35
87
|
}
|
|
36
88
|
}
|
|
37
|
-
|
|
89
|
+
const uniqueColumns = Array.from(new Set(columns));
|
|
90
|
+
return { columns: uniqueColumns, relationshipTree, invalidFields };
|
|
38
91
|
};
|
|
39
|
-
buildSequelizeInclude = (tree, model) => {
|
|
92
|
+
buildSequelizeInclude = (tree, model, depth = 0, visitedModels = new Set()) => {
|
|
93
|
+
const MAX_DEPTH = 10;
|
|
94
|
+
if (depth > MAX_DEPTH) {
|
|
95
|
+
console.warn("Maximum include depth exceeded.");
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
if (visitedModels.has(model)) {
|
|
99
|
+
console.warn("Circular relationship detected.");
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
visitedModels.add(model);
|
|
40
103
|
const includeOptions = Object.entries(tree).flatMap(([relation, nested]) => {
|
|
41
104
|
const relationship = model.associations?.[relation];
|
|
42
105
|
if (!relationship || !relationship.target)
|
|
@@ -50,18 +113,34 @@ class FieldParserService {
|
|
|
50
113
|
attributes: [],
|
|
51
114
|
};
|
|
52
115
|
}
|
|
53
|
-
const deeperIncludes = this.buildSequelizeInclude(nested, currentModel);
|
|
116
|
+
const deeperIncludes = this.buildSequelizeInclude(nested, currentModel, depth + 1, visitedModels);
|
|
54
117
|
const leafAttributes = Object.entries(nested)
|
|
55
118
|
.filter(([, value]) => value === true)
|
|
56
|
-
.map(([key]) => key)
|
|
57
|
-
|
|
119
|
+
.map(([key]) => key);
|
|
120
|
+
const filteredLeafAttributes = leafAttributes.filter((attr) => {
|
|
121
|
+
const isSelectable = selectableFields.includes(attr);
|
|
122
|
+
if (!isSelectable) {
|
|
123
|
+
console.warn(`FieldParserService: Attribute '${attr}' in relationship '${relation}' is not in SELECTABLE_FIELDS and will be ignored.`);
|
|
124
|
+
}
|
|
125
|
+
return isSelectable;
|
|
126
|
+
});
|
|
127
|
+
let attributesToUse = filteredLeafAttributes;
|
|
128
|
+
if (attributesToUse.length === 0) {
|
|
129
|
+
if (Array.isArray(currentModel.DEFAULT_FIELDS)) {
|
|
130
|
+
attributesToUse = [...currentModel.DEFAULT_FIELDS];
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.warn(`FieldParserService: No attributes selected and no DEFAULT_FIELDS available for relationship '${relation}'. Attributes array will be empty.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
58
136
|
return {
|
|
59
137
|
model: currentModel,
|
|
60
138
|
as: relation,
|
|
61
|
-
attributes:
|
|
139
|
+
attributes: attributesToUse,
|
|
62
140
|
include: deeperIncludes,
|
|
63
141
|
};
|
|
64
142
|
});
|
|
143
|
+
visitedModels.delete(model);
|
|
65
144
|
return includeOptions;
|
|
66
145
|
};
|
|
67
146
|
}
|