unconventional 1.16.0 → 1.18.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.
@@ -34,7 +34,7 @@ export declare class BaseModel extends Document {
34
34
  }): Promise<InstanceType<T> | null>;
35
35
  static truncate<T extends typeof BaseModel>(this: T): Promise<void>;
36
36
  static fromDatabaseJson<T extends typeof BaseModel>(this: T, json: object): InstanceType<T>;
37
- private static addDefaults;
37
+ private static prepareData;
38
38
  /** Gets the field name to use for the provided identifier */
39
39
  private static getFieldName;
40
40
  /** Masks private fields and in the provided data and its relations */
@@ -37,7 +37,7 @@ class BaseModel extends Document {
37
37
  type: unconventional_pg_queries_1.OneOrMany.One,
38
38
  table: this.collection,
39
39
  conflict: (0, db_utils_1.getConflict)(this, upsertConfig?.action || unconventional_pg_queries_1.ConflictResolution.doUpdate, upsertConfig?.constraint),
40
- data: this.addDefaults({ ...data, ...timestamp })
40
+ data: this.prepareData({ ...data, ...timestamp })
41
41
  });
42
42
  if (!response)
43
43
  return null;
@@ -54,7 +54,7 @@ class BaseModel extends Document {
54
54
  type: unconventional_pg_queries_1.OneOrMany.Many,
55
55
  table: this.collection,
56
56
  conflict: (0, db_utils_1.getConflict)(this, upsertConfig?.action || unconventional_pg_queries_1.ConflictResolution.doUpdate, upsertConfig?.constraint),
57
- data: data.map(item => this.addDefaults({ ...item, ...timestamp }))
57
+ data: data.map(item => this.prepareData({ ...item, ...timestamp }))
58
58
  });
59
59
  if (!response)
60
60
  return null;
@@ -108,7 +108,7 @@ class BaseModel extends Document {
108
108
  type: unconventional_pg_queries_1.OneOrMany.One,
109
109
  table: this.collection,
110
110
  where: [{ field: targetField, operator: unconventional_pg_queries_1.SqlWhereOperator.Eq, value: id }],
111
- data: { ...data, ...timestamp }
111
+ data: this.prepareData({ ...data, ...timestamp }, false)
112
112
  });
113
113
  if (!response)
114
114
  return null;
@@ -121,7 +121,7 @@ class BaseModel extends Document {
121
121
  action: db_types_1.SqlAction.Update,
122
122
  type: unconventional_pg_queries_1.OneOrMany.Many,
123
123
  table: this.collection,
124
- data: data.map(item => ({ ...item, ...timestamp }))
124
+ data: data.map(item => this.prepareData({ ...item, ...timestamp }, false))
125
125
  });
126
126
  if (!response)
127
127
  return null;
@@ -196,12 +196,17 @@ class BaseModel extends Document {
196
196
  Object.assign(model, json);
197
197
  return model;
198
198
  }
199
- static addDefaults(data) {
199
+ static prepareData(data, applyDefaults = true) {
200
200
  Object.entries(this.schema.props).forEach(([key, options]) => {
201
- if (data[key] === undefined && options.default != null) {
201
+ // Apply default
202
+ if (applyDefaults && data[key] === undefined && options.default != null) {
202
203
  const val = options.default;
203
204
  data[key] = typeof val === "function" ? val() : val;
204
205
  }
206
+ // Apply preFormat
207
+ if (data[key] !== undefined && options.preFormat != null) {
208
+ data[key] = options.preFormat(data[key]);
209
+ }
205
210
  });
206
211
  return data;
207
212
  }
@@ -68,6 +68,8 @@ export interface PropOptions<T = any, S extends typeof BaseModel = any, P extend
68
68
  private?: boolean | ((model: InstanceType<S>, field: T extends typeof BaseModel ? InstanceType<T> : T) => boolean);
69
69
  /** Relational field data */
70
70
  relation?: T extends typeof BaseModel ? Relation<T, S, P> : never;
71
+ /** Format the field value before it is stored in the database */
72
+ preFormat?: (fieldValue: T extends typeof BaseModel ? InstanceType<T> : T) => any;
71
73
  }
72
74
  /** Type for the Values stored in the Reflection for Properties */
73
75
  export type PropSummary<Target = any, Source extends typeof BaseModel = any> = Record<keyof Source, PropOptions<Target, Source>>;
@@ -35,66 +35,138 @@ function getExpand(model, expand) {
35
35
  return expansions;
36
36
  }
37
37
  exports.getExpand = getExpand;
38
+ /** Parses a single filter condition into a SqlWhere object */
39
+ function parseSingleFilter(model, filter, andOr) {
40
+ const propSummary = model.schema.props;
41
+ const singleQuoteIndex = filter.indexOf("'");
42
+ const matchingOperators = singleQuoteIndex === -1 ?
43
+ db_types_1.ValidSqlOperators.filter(op => filter.includes(` ${op}`)) :
44
+ db_types_1.ValidSqlOperators.filter(op => filter.includes(` ${op}`) && filter.indexOf(` ${op}`) < singleQuoteIndex);
45
+ if (!matchingOperators.length)
46
+ throw api_error_1.APIError.errInvalidQueryParameter(`No valid operator in filter: '${filter}'`);
47
+ // Handle when multiple operators are present in the filter (e.g. LIKE and NOT LIKE)
48
+ let operator = matchingOperators.sort((a, b) => b.length - a.length)[0];
49
+ let field = filter.substring(0, filter.indexOf(operator)).trim();
50
+ let value = filter.substring(filter.indexOf(operator) + operator.length).trim();
51
+ let jsonPath = [];
52
+ let relationPath = undefined;
53
+ // Handle IS NULL and IS NOT NULL, which don't have values
54
+ if (operator.startsWith('IS')) {
55
+ value = null;
56
+ }
57
+ // Handle JSON path
58
+ if (field.includes('.')) {
59
+ jsonPath = field.split('.');
60
+ const target = jsonPath.shift();
61
+ // Split relational fields into relation and field. e.g. 'user.name' -> 'user' and 'name'
62
+ // Otherwise, assume json
63
+ const relation = propSummary[target]?.relation;
64
+ if (relation) {
65
+ if (jsonPath.length !== 1)
66
+ throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'. Relation filters must be in the format: 'relation.field'`);
67
+ const relationModel = (0, api_utils_1.getRelationModel)(relation);
68
+ if (!relationModel.schema.props[jsonPath[0]])
69
+ throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'.`);
70
+ relationPath = target;
71
+ field = jsonPath.shift();
72
+ }
73
+ else {
74
+ field = target;
75
+ }
76
+ }
77
+ if (!relationPath && !propSummary[field])
78
+ throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'`);
79
+ if (!value && !db_types_1.NullSqlOperators.includes(operator))
80
+ throw api_error_1.APIError.errInvalidQueryParameter(`Invalid value in filter: '${value}'`);
81
+ return { field, jsonPath, relationPath, operator: operator, value, andOr };
82
+ }
83
+ /** Extracts content within parentheses and returns it along with the position after the closing paren */
84
+ function extractParenthesesContent(str, startIndex) {
85
+ let depth = 1;
86
+ let i = startIndex + 1;
87
+ while (i < str.length && depth > 0) {
88
+ if (str[i] === '(')
89
+ depth++;
90
+ else if (str[i] === ')')
91
+ depth--;
92
+ i++;
93
+ }
94
+ if (depth !== 0)
95
+ throw api_error_1.APIError.errInvalidQueryParameter(`Unmatched parentheses in filter`);
96
+ return {
97
+ content: str.substring(startIndex + 1, i - 1),
98
+ endIndex: i
99
+ };
100
+ }
101
+ /** Checks if a parenthesis is part of an operator value (e.g., IN (...)) or a compound clause */
102
+ function isOperatorValueParen(str, parenIndex) {
103
+ // Look backwards to see if there's an operator that uses parentheses for values
104
+ const beforeParen = str.substring(0, parenIndex).trimEnd();
105
+ // Check for operators that use parentheses: IN, NOT IN
106
+ return beforeParen.endsWith(' IN') || beforeParen.endsWith(' NOT IN');
107
+ }
38
108
  /** Converts a string of filters to an array of 'where' clause outlines for a SQL environment */
39
109
  function getWhere(model, filterString) {
40
110
  if (!filterString)
41
111
  return [];
42
- const propSummary = model.schema.props;
43
112
  const wheres = [];
44
- if (filterString) {
45
- filterString.split(db_types_1.andOrPattern).forEach((filter) => {
46
- // Handle logically combined clauses
47
- let andOr = undefined;
48
- if (filter.startsWith(unconventional_pg_queries_1.AndOr.And)) {
49
- filter = filter.substring(unconventional_pg_queries_1.AndOr.And.length + 1);
50
- andOr = unconventional_pg_queries_1.AndOr.And;
51
- }
52
- else if (filter.startsWith(unconventional_pg_queries_1.AndOr.Or)) {
53
- filter = filter.substring(unconventional_pg_queries_1.AndOr.Or.length + 1);
54
- andOr = unconventional_pg_queries_1.AndOr.Or;
55
- }
56
- const singleQuoteIndex = filter.indexOf("'");
57
- const matchingOperators = singleQuoteIndex === -1 ?
58
- db_types_1.ValidSqlOperators.filter(op => filter.includes(` ${op}`)) :
59
- db_types_1.ValidSqlOperators.filter(op => filter.includes(` ${op}`) && filter.indexOf(` ${op}`) < singleQuoteIndex);
60
- if (!matchingOperators.length)
61
- throw api_error_1.APIError.errInvalidQueryParameter(`No valid operator in filter: '${filter}'`);
62
- // Handle when multiple operators are present in the filter (e.g. LIKE and NOT LIKE)
63
- let operator = matchingOperators.sort((a, b) => b.length - a.length)[0];
64
- let field = filter.substring(0, filter.indexOf(operator)).trim();
65
- let value = filter.substring(filter.indexOf(operator) + operator.length).trim();
66
- let jsonPath = [];
67
- let relationPath = undefined;
68
- // Handle IS NULL and IS NOT NULL, which don't have values
69
- if (operator.startsWith('IS')) {
70
- value = null;
113
+ let i = 0;
114
+ let currentFilter = '';
115
+ let currentAndOr = undefined;
116
+ while (i < filterString.length) {
117
+ const char = filterString[i];
118
+ // Check for closing parenthesis without matching opening (error case)
119
+ if (char === ')' && !currentFilter.includes('(')) {
120
+ throw api_error_1.APIError.errInvalidQueryParameter('Unmatched closing parenthesis in filter');
121
+ }
122
+ // Check for opening parenthesis (but distinguish compound clauses from operator values)
123
+ if (char === '(' && !isOperatorValueParen(filterString, i)) {
124
+ const { content, endIndex } = extractParenthesesContent(filterString, i);
125
+ // Recursively parse the compound clause
126
+ const nestedClauses = getWhere(model, content);
127
+ if (nestedClauses.length > 0) {
128
+ // Create a wrapper clause using the first nested clause's properties
129
+ // and add the rest as sub-clauses
130
+ const [firstClause, ...restClauses] = nestedClauses;
131
+ const compoundClause = {
132
+ ...firstClause,
133
+ andOr: currentAndOr,
134
+ clauses: restClauses.length > 0 ? restClauses : undefined
135
+ };
136
+ wheres.push(compoundClause);
71
137
  }
72
- // Handle JSON path
73
- if (field.includes('.')) {
74
- jsonPath = field.split('.');
75
- const target = jsonPath.shift();
76
- // Split relational fields into relation and field. e.g. 'user.name' -> 'user' and 'name'
77
- // Otherwise, assume json
78
- const relation = propSummary[target]?.relation;
79
- if (relation) {
80
- if (jsonPath.length !== 1)
81
- throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'. Relation filters must be in the format: 'relation.field'`);
82
- const relationModel = (0, api_utils_1.getRelationModel)(relation);
83
- if (!relationModel.schema.props[jsonPath[0]])
84
- throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'.`);
85
- relationPath = target;
86
- field = jsonPath.shift();
138
+ i = endIndex;
139
+ currentFilter = '';
140
+ currentAndOr = undefined;
141
+ continue;
142
+ }
143
+ // Check for AND/OR keywords
144
+ if (i === 0 || filterString[i - 1] === ' ' || filterString[i - 1] === ')') {
145
+ if (filterString.substring(i, i + 4) === 'AND ') {
146
+ if (currentFilter.trim()) {
147
+ wheres.push(parseSingleFilter(model, currentFilter.trim(), currentAndOr));
148
+ currentFilter = '';
87
149
  }
88
- else {
89
- field = target;
150
+ currentAndOr = unconventional_pg_queries_1.AndOr.And;
151
+ i += 4;
152
+ continue;
153
+ }
154
+ else if (filterString.substring(i, i + 3) === 'OR ') {
155
+ if (currentFilter.trim()) {
156
+ wheres.push(parseSingleFilter(model, currentFilter.trim(), currentAndOr));
157
+ currentFilter = '';
90
158
  }
159
+ currentAndOr = unconventional_pg_queries_1.AndOr.Or;
160
+ i += 3;
161
+ continue;
91
162
  }
92
- if (!relationPath && !propSummary[field])
93
- throw api_error_1.APIError.errInvalidQueryParameter(`Invalid filter: '${field}'`);
94
- if (!value && !db_types_1.NullSqlOperators.includes(operator))
95
- throw api_error_1.APIError.errInvalidQueryParameter(`Invalid value in filter: '${value}'`);
96
- wheres.push({ field, jsonPath, relationPath, operator: operator, value, andOr });
97
- });
163
+ }
164
+ currentFilter += char;
165
+ i++;
166
+ }
167
+ // Process any remaining filter
168
+ if (currentFilter.trim()) {
169
+ wheres.push(parseSingleFilter(model, currentFilter.trim(), currentAndOr));
98
170
  }
99
171
  return wheres;
100
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unconventional",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "A complete server built on Cloudflare Workers/Pages",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -33,6 +33,7 @@
33
33
  "clean": "tsc --build --clean",
34
34
  "build": "rm -rf dist && tsc --build",
35
35
  "watch": "tsc --watch",
36
+ "test": "for file in src/tests/*.test.ts; do tsx \"$file\" || exit 1; done",
36
37
  "lint": "eslint . --ext js,jsx,ts,tsx",
37
38
  "format": "eslint . --ext js,jsx,ts,tsx --fix",
38
39
  "docs:dev": "vitepress dev docs",
@@ -45,14 +46,15 @@
45
46
  },
46
47
  "peerDependencies": {
47
48
  "hono": "^4",
48
- "unconventional-pg-queries": "^1.5.0"
49
+ "unconventional-pg-queries": "^1.6.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@cloudflare/workers-types": "^4.20231121.0",
52
53
  "@typescript-eslint/eslint-plugin": "^5.62.0",
53
54
  "@typescript-eslint/parser": "^5.62.0",
54
55
  "hono": "^4",
56
+ "tsx": "^4.20.6",
55
57
  "typescript": "^5.3.3",
56
- "unconventional-pg-queries": "^1.5.0"
58
+ "unconventional-pg-queries": "^1.6.0"
57
59
  }
58
60
  }