@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 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
- Import and use in your project:
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
- import FieldParserService from "sequelize-field-parser";
30
- import Status from "./path/to/status.model";
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
- // query parameter
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
- // Parse the query parameter
40
- const { columns, relationshipTree } = parser.parseFields(queryParams, Model);
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
- // Build the sequelize include
43
- const include = parser.buildSequelizeInclude(relationshipTree, Model);
112
+ ### Deeply Nested/Circular Relationships
44
113
 
45
- console.log(include);
46
- /* Example output:
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: Status,
50
- as: 'status',
51
- attributes: ['uuid', 'name'],
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 columns = [...model.DEFAULT_FIELDS];
15
- const selectableFields = model.SELECTABLE_FIELDS;
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 field = segments[i];
28
- if (!relationship[field]) {
29
- relationship[field] = i === segments.length - 1 ? true : {};
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
- if (relationship[field] !== true) {
32
- relationship = relationship[field];
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
- return { columns, relationshipTree };
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
- .filter((attr) => selectableFields.includes(attr));
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: leafAttributes,
139
+ attributes: attributesToUse,
62
140
  include: deeperIncludes,
63
141
  };
64
142
  });
143
+ visitedModels.delete(model);
65
144
  return includeOptions;
66
145
  };
67
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ealforque/sequelize-field-parser",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "main": "dist/field_parser.service.js",
5
5
  "types": "dist/field_parser.service.d.ts",
6
6
  "files": [