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
|
|
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 */
|
package/dist/core/base.model.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
199
|
+
static prepareData(data, applyDefaults = true) {
|
|
200
200
|
Object.entries(this.schema.props).forEach(([key, options]) => {
|
|
201
|
-
|
|
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>>;
|
package/dist/utils/db.utils.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
58
|
+
"unconventional-pg-queries": "^1.6.0"
|
|
57
59
|
}
|
|
58
60
|
}
|