unconventional 1.15.0 → 1.17.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/dist/utils/db.utils.js +123 -51
- package/package.json +5 -3
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.17.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
|
}
|