@yunsoft/yuncms-core 0.1.5 → 0.1.6
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/package.json +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +2 -0
- package/src/config.js +102 -57
- package/src/hooks.js +117 -32
- package/src/index.js +23 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/query.js +131 -71
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +511 -223
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/items-service.js +80 -93
package/src/mail/smtp-mailer.js
CHANGED
|
@@ -13,6 +13,25 @@ function assertAddress(value, label) {
|
|
|
13
13
|
return value.trim();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function normalizeMessage({ to, subject, text, html = undefined } = {}) {
|
|
17
|
+
const recipient = assertAddress(to, 'Recipient');
|
|
18
|
+
if (typeof subject !== 'string' || !subject.trim() || /[\r\n]/.test(subject)) {
|
|
19
|
+
throw mailError('INVALID_MAIL_MESSAGE', 'Mail subject is invalid');
|
|
20
|
+
}
|
|
21
|
+
if (typeof text !== 'string' || !text) {
|
|
22
|
+
throw mailError('INVALID_MAIL_MESSAGE', 'Mail text body is required');
|
|
23
|
+
}
|
|
24
|
+
if (html !== undefined && typeof html !== 'string') {
|
|
25
|
+
throw mailError('INVALID_MAIL_MESSAGE', 'Mail HTML body must be a string');
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
to: recipient,
|
|
29
|
+
subject: subject.trim(),
|
|
30
|
+
text,
|
|
31
|
+
...(html ? { html } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
export class SmtpMailer {
|
|
17
36
|
constructor({
|
|
18
37
|
host,
|
|
@@ -22,9 +41,11 @@ export class SmtpMailer {
|
|
|
22
41
|
password = null,
|
|
23
42
|
from,
|
|
24
43
|
transport = null,
|
|
44
|
+
emitter = null,
|
|
25
45
|
} = {}) {
|
|
26
46
|
if (!transport && (!host || typeof host !== 'string')) throw new Error('SMTP host is required');
|
|
27
47
|
this.from = assertAddress(from, 'SMTP from address');
|
|
48
|
+
this.emitter = emitter;
|
|
28
49
|
this.transport = transport ?? nodemailer.createTransport({
|
|
29
50
|
host,
|
|
30
51
|
port,
|
|
@@ -40,26 +61,49 @@ export class SmtpMailer {
|
|
|
40
61
|
});
|
|
41
62
|
}
|
|
42
63
|
|
|
64
|
+
setEmitter(emitter) {
|
|
65
|
+
this.emitter = emitter;
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
async verify() {
|
|
44
70
|
if (typeof this.transport.verify !== 'function') return true;
|
|
45
71
|
return this.transport.verify();
|
|
46
72
|
}
|
|
47
73
|
|
|
48
|
-
async send(
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
async send(message = {}, context = {}) {
|
|
75
|
+
let normalized = normalizeMessage(message);
|
|
76
|
+
if (this.emitter) {
|
|
77
|
+
normalized = normalizeMessage(await this.emitter.filter('mail.send', normalized, {
|
|
78
|
+
accountability: context.accountability ?? null,
|
|
79
|
+
requestId: context.requestId ?? null,
|
|
80
|
+
}));
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
83
|
+
try {
|
|
84
|
+
const result = await this.transport.sendMail({
|
|
85
|
+
from: this.from,
|
|
86
|
+
...normalized,
|
|
87
|
+
});
|
|
88
|
+
await this.emitter?.action('mail.sent', {
|
|
89
|
+
to: normalized.to,
|
|
90
|
+
subject: normalized.subject,
|
|
91
|
+
messageId: result?.messageId ?? null,
|
|
92
|
+
}, {
|
|
93
|
+
accountability: context.accountability ?? null,
|
|
94
|
+
requestId: context.requestId ?? null,
|
|
95
|
+
});
|
|
96
|
+
return result;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await this.emitter?.action('mail.failed', {
|
|
99
|
+
to: normalized.to,
|
|
100
|
+
subject: normalized.subject,
|
|
101
|
+
code: error?.code ?? 'MAIL_DELIVERY_FAILED',
|
|
102
|
+
}, {
|
|
103
|
+
accountability: context.accountability ?? null,
|
|
104
|
+
requestId: context.requestId ?? null,
|
|
105
|
+
});
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
64
108
|
}
|
|
65
109
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const externalAuthFoundationMigration = {
|
|
2
|
+
id: '0013-external-auth-foundation',
|
|
3
|
+
statements: [
|
|
4
|
+
`CREATE TABLE IF NOT EXISTS yuncms_auth_identities (
|
|
5
|
+
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
6
|
+
provider VARCHAR(64) NOT NULL,
|
|
7
|
+
subject VARCHAR(255) NOT NULL,
|
|
8
|
+
user CHAR(36) NOT NULL,
|
|
9
|
+
email VARCHAR(191) NULL,
|
|
10
|
+
profile JSON NULL,
|
|
11
|
+
last_login_at DATETIME(3) NULL,
|
|
12
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
13
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
14
|
+
UNIQUE KEY uq_yuncms_auth_identity_provider_subject (provider, subject),
|
|
15
|
+
KEY idx_yuncms_auth_identity_user (user),
|
|
16
|
+
CONSTRAINT fk_yuncms_auth_identity_user FOREIGN KEY (user)
|
|
17
|
+
REFERENCES yuncms_users (id) ON DELETE CASCADE
|
|
18
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
19
|
+
|
|
20
|
+
`CREATE TABLE IF NOT EXISTS yuncms_auth_transactions (
|
|
21
|
+
id CHAR(36) NOT NULL PRIMARY KEY,
|
|
22
|
+
provider VARCHAR(64) NOT NULL,
|
|
23
|
+
state_hash CHAR(64) NOT NULL,
|
|
24
|
+
secret_ciphertext TEXT NULL,
|
|
25
|
+
redirect_target VARCHAR(512) NULL,
|
|
26
|
+
metadata JSON NULL,
|
|
27
|
+
expires_at DATETIME(3) NOT NULL,
|
|
28
|
+
used_at DATETIME(3) NULL,
|
|
29
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
30
|
+
UNIQUE KEY uq_yuncms_auth_transaction_state (state_hash),
|
|
31
|
+
KEY idx_yuncms_auth_transaction_provider_expiry (provider, expires_at),
|
|
32
|
+
KEY idx_yuncms_auth_transaction_expiry (expires_at)
|
|
33
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
34
|
+
],
|
|
35
|
+
};
|
package/src/query.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import { quoteIdentifier } from './identifier.js';
|
|
2
2
|
|
|
3
|
-
const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset']);
|
|
3
|
+
const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
|
|
4
4
|
const FILTER_OPERATORS = new Set([
|
|
5
5
|
'_eq', '_neq', '_lt', '_lte', '_gt', '_gte',
|
|
6
6
|
'_in', '_nin', '_null', '_nnull',
|
|
7
7
|
'_contains', '_starts_with', '_ends_with',
|
|
8
8
|
]);
|
|
9
|
+
const AGGREGATE_FUNCTIONS = new Set(['count', 'countDistinct', 'sum', 'avg', 'min', 'max']);
|
|
10
|
+
const SEARCHABLE_TYPES = new Set(['string', 'text']);
|
|
9
11
|
|
|
10
12
|
export const QUERY_LIMITS = Object.freeze({
|
|
11
13
|
defaultLimit: 100,
|
|
12
14
|
maxLimit: 500,
|
|
13
15
|
maxFields: 100,
|
|
14
16
|
maxRelationExpansions: 20,
|
|
17
|
+
maxRelationDepth: 4,
|
|
15
18
|
maxSortFields: 20,
|
|
16
19
|
maxOffset: 1_000_000,
|
|
17
20
|
maxFilterDepth: 8,
|
|
18
21
|
maxFilterNodes: 100,
|
|
19
22
|
maxInValues: 100,
|
|
23
|
+
maxSearchLength: 200,
|
|
24
|
+
maxAggregateFields: 20,
|
|
25
|
+
maxGroupByFields: 10,
|
|
26
|
+
maxCost: 2_000,
|
|
20
27
|
});
|
|
21
28
|
|
|
22
|
-
function queryError(message, path = null) {
|
|
29
|
+
function queryError(message, path = null, code = 'INVALID_QUERY') {
|
|
23
30
|
const error = new Error(message);
|
|
24
|
-
error.code =
|
|
31
|
+
error.code = code;
|
|
25
32
|
if (path) error.path = path;
|
|
26
33
|
return error;
|
|
27
34
|
}
|
|
@@ -51,45 +58,92 @@ function normalizeFilter(value) {
|
|
|
51
58
|
if (typeof value === 'string') {
|
|
52
59
|
try {
|
|
53
60
|
const parsed = JSON.parse(value);
|
|
54
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
55
|
-
throw new Error('not an object');
|
|
56
|
-
}
|
|
61
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
57
62
|
return parsed;
|
|
58
63
|
} catch {
|
|
59
64
|
throw queryError('filter must be a valid JSON object', 'filter');
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
|
-
if (typeof value !== 'object' || Array.isArray(value))
|
|
63
|
-
throw queryError('filter must be an object', 'filter');
|
|
64
|
-
}
|
|
67
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw queryError('filter must be an object', 'filter');
|
|
65
68
|
return value;
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
function normalizeSearch(value, limits) {
|
|
72
|
+
if (value == null || value === '') return null;
|
|
73
|
+
if (Array.isArray(value) || typeof value === 'object') throw queryError('search must be a string', 'search');
|
|
74
|
+
const search = String(value).trim();
|
|
75
|
+
if (!search) return null;
|
|
76
|
+
if (search.length > limits.maxSearchLength) {
|
|
77
|
+
throw queryError(`search cannot exceed ${limits.maxSearchLength} characters`, 'search');
|
|
78
|
+
}
|
|
79
|
+
return search;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeAggregate(value, limits) {
|
|
83
|
+
if (value == null || value === '') return null;
|
|
84
|
+
let aggregate = value;
|
|
85
|
+
if (typeof aggregate === 'string') {
|
|
86
|
+
try { aggregate = JSON.parse(aggregate); } catch { throw queryError('aggregate must be a JSON object', 'aggregate'); }
|
|
87
|
+
}
|
|
88
|
+
if (!aggregate || typeof aggregate !== 'object' || Array.isArray(aggregate)) {
|
|
89
|
+
throw queryError('aggregate must be an object', 'aggregate');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const normalized = {};
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const [fn, rawFields] of Object.entries(aggregate)) {
|
|
95
|
+
if (!AGGREGATE_FUNCTIONS.has(fn)) throw queryError(`Unknown aggregate function: ${fn}`, `aggregate.${fn}`);
|
|
96
|
+
const fields = normalizeDelimited(rawFields, `aggregate.${fn}`, { maxItems: limits.maxAggregateFields });
|
|
97
|
+
if (!fields) throw queryError(`aggregate.${fn} cannot be empty`, `aggregate.${fn}`);
|
|
98
|
+
count += fields.length;
|
|
99
|
+
if (count > limits.maxAggregateFields) {
|
|
100
|
+
throw queryError(`aggregate cannot contain more than ${limits.maxAggregateFields} fields`, 'aggregate');
|
|
101
|
+
}
|
|
102
|
+
normalized[fn] = fields;
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(normalized).length === 0) throw queryError('aggregate cannot be empty', 'aggregate');
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
|
|
68
108
|
export function parseItemsQuery(raw = {}, options = {}) {
|
|
69
109
|
const limits = { ...QUERY_LIMITS, ...options };
|
|
70
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
71
|
-
throw queryError('Query must be an object');
|
|
72
|
-
}
|
|
110
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw queryError('Query must be an object');
|
|
73
111
|
|
|
74
112
|
for (const key of Object.keys(raw)) {
|
|
75
113
|
if (!QUERY_KEYS.has(key)) throw queryError(`Unknown query parameter: ${key}`, key);
|
|
76
114
|
}
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
const query = {
|
|
79
117
|
fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
|
|
80
118
|
filter: normalizeFilter(raw.filter),
|
|
81
119
|
sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
|
|
82
|
-
limit: normalizeInteger(raw.limit, limits.defaultLimit, {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}),
|
|
87
|
-
offset: normalizeInteger(raw.offset, 0, {
|
|
88
|
-
label: 'offset',
|
|
89
|
-
min: 0,
|
|
90
|
-
max: limits.maxOffset,
|
|
91
|
-
}),
|
|
120
|
+
limit: normalizeInteger(raw.limit, limits.defaultLimit, { label: 'limit', min: 1, max: limits.maxLimit }),
|
|
121
|
+
offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: limits.maxOffset }),
|
|
122
|
+
search: normalizeSearch(raw.search, limits),
|
|
123
|
+
aggregate: normalizeAggregate(raw.aggregate, limits),
|
|
124
|
+
groupBy: normalizeDelimited(raw.groupBy, 'groupBy', { maxItems: limits.maxGroupByFields }),
|
|
92
125
|
};
|
|
126
|
+
|
|
127
|
+
if (query.groupBy && !query.aggregate) throw queryError('groupBy requires aggregate', 'groupBy');
|
|
128
|
+
return Object.freeze(query);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function queryCost(query, { relationDepth = 0, relationCount = 0 } = {}) {
|
|
132
|
+
let cost = 1 + Number(query?.limit ?? QUERY_LIMITS.defaultLimit);
|
|
133
|
+
cost += (query?.fields?.length ?? 0) * 2;
|
|
134
|
+
cost += (query?.sort?.length ?? 0) * 5;
|
|
135
|
+
cost += relationCount * 50;
|
|
136
|
+
cost += relationDepth * 100;
|
|
137
|
+
if (query?.search) cost += 100;
|
|
138
|
+
if (query?.aggregate) cost += 250 + Object.values(query.aggregate).flat().length * 25;
|
|
139
|
+
return cost;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function assertQueryCost(query, options = {}) {
|
|
143
|
+
const maxCost = options.maxCost ?? QUERY_LIMITS.maxCost;
|
|
144
|
+
const cost = queryCost(query, options);
|
|
145
|
+
if (cost > maxCost) throw queryError(`Query cost ${cost} exceeds limit ${maxCost}`, null, 'QUERY_COST_LIMIT');
|
|
146
|
+
return cost;
|
|
93
147
|
}
|
|
94
148
|
|
|
95
149
|
function resolveField(schema, field, path = field) {
|
|
@@ -100,7 +154,6 @@ function resolveField(schema, field, path = field) {
|
|
|
100
154
|
export function compileSelectFields(fields, schema) {
|
|
101
155
|
const selected = !fields || fields.includes('*') ? Object.keys(schema.fields) : fields;
|
|
102
156
|
if (selected.length === 0) throw queryError('At least one field must be selected', 'fields');
|
|
103
|
-
|
|
104
157
|
const unique = [...new Set(selected)];
|
|
105
158
|
return {
|
|
106
159
|
fields: unique,
|
|
@@ -113,7 +166,6 @@ export function compileSelectFields(fields, schema) {
|
|
|
113
166
|
|
|
114
167
|
export function compileSort(sort, schema) {
|
|
115
168
|
if (!sort) return '';
|
|
116
|
-
|
|
117
169
|
const parts = sort.map((entry, index) => {
|
|
118
170
|
const descending = entry.startsWith('-');
|
|
119
171
|
const field = descending ? entry.slice(1) : entry;
|
|
@@ -121,7 +173,6 @@ export function compileSort(sort, schema) {
|
|
|
121
173
|
resolveField(schema, field, `sort.${index}`);
|
|
122
174
|
return `${quoteIdentifier(field, 'field name')} ${descending ? 'DESC' : 'ASC'}`;
|
|
123
175
|
});
|
|
124
|
-
|
|
125
176
|
return parts.length ? ` ORDER BY ${parts.join(', ')}` : '';
|
|
126
177
|
}
|
|
127
178
|
|
|
@@ -129,9 +180,49 @@ function escapeLike(value) {
|
|
|
129
180
|
return String(value).replace(/[\\%_]/g, '\\$&');
|
|
130
181
|
}
|
|
131
182
|
|
|
183
|
+
export function compileSearch(search, schema) {
|
|
184
|
+
if (!search) return { sql: '', params: [] };
|
|
185
|
+
const fields = Object.entries(schema?.fields ?? {})
|
|
186
|
+
.filter(([, field]) => SEARCHABLE_TYPES.has(field.type))
|
|
187
|
+
.map(([name]) => name);
|
|
188
|
+
if (fields.length === 0) return { sql: ' WHERE 0 = 1', params: [] };
|
|
189
|
+
const needle = `%${escapeLike(search)}%`;
|
|
190
|
+
return {
|
|
191
|
+
sql: ` WHERE (${fields.map((field) => `${quoteIdentifier(field, 'field name')} LIKE ? ESCAPE '\\\\'`).join(' OR ')})`,
|
|
192
|
+
params: fields.map(() => needle),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function compileAggregate(aggregate, groupBy, schema) {
|
|
197
|
+
if (!aggregate) return null;
|
|
198
|
+
const groups = groupBy ?? [];
|
|
199
|
+
for (const field of groups) resolveField(schema, field, `groupBy.${field}`);
|
|
200
|
+
const selections = groups.map((field) => quoteIdentifier(field, 'field name'));
|
|
201
|
+
const aliases = new Set(groups);
|
|
202
|
+
|
|
203
|
+
for (const [fn, fields] of Object.entries(aggregate)) {
|
|
204
|
+
for (const field of fields) {
|
|
205
|
+
if (field === '*' && fn !== 'count') throw queryError(`${fn} does not support *`, `aggregate.${fn}`);
|
|
206
|
+
if (field !== '*') resolveField(schema, field, `aggregate.${fn}.${field}`);
|
|
207
|
+
const column = field === '*' ? '*' : quoteIdentifier(field, 'field name');
|
|
208
|
+
const sqlFn = fn === 'countDistinct' ? 'COUNT' : fn.toUpperCase();
|
|
209
|
+
const expression = fn === 'countDistinct' ? `${sqlFn}(DISTINCT ${column})` : `${sqlFn}(${column})`;
|
|
210
|
+
const rawAlias = field === '*' ? fn : `${fn}_${field}`;
|
|
211
|
+
let alias = rawAlias;
|
|
212
|
+
let suffix = 2;
|
|
213
|
+
while (aliases.has(alias)) alias = `${rawAlias}_${suffix++}`;
|
|
214
|
+
aliases.add(alias);
|
|
215
|
+
selections.push(`${expression} AS ${quoteIdentifier(alias, 'aggregate alias')}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
sql: selections.join(', '),
|
|
220
|
+
groupSql: groups.length ? ` GROUP BY ${groups.map((field) => quoteIdentifier(field, 'field name')).join(', ')}` : '',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
132
224
|
function compileOperator(fieldSql, operator, value, path, limits) {
|
|
133
225
|
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
134
|
-
|
|
135
226
|
switch (operator) {
|
|
136
227
|
case '_eq':
|
|
137
228
|
if (value === null) throw queryError('Use _null for NULL comparisons', path);
|
|
@@ -146,15 +237,10 @@ function compileOperator(fieldSql, operator, value, path, limits) {
|
|
|
146
237
|
case '_in':
|
|
147
238
|
case '_nin': {
|
|
148
239
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
149
|
-
if (value.length > limits.maxInValues) {
|
|
150
|
-
throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
151
|
-
}
|
|
240
|
+
if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
152
241
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
153
242
|
const placeholders = value.map(() => '?').join(', ');
|
|
154
|
-
return {
|
|
155
|
-
sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`,
|
|
156
|
-
params: value,
|
|
157
|
-
};
|
|
243
|
+
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
|
|
158
244
|
}
|
|
159
245
|
case '_null':
|
|
160
246
|
case '_nnull': {
|
|
@@ -162,67 +248,41 @@ function compileOperator(fieldSql, operator, value, path, limits) {
|
|
|
162
248
|
const wantsNull = operator === '_null' ? value : !value;
|
|
163
249
|
return { sql: `${fieldSql} IS ${wantsNull ? '' : 'NOT '}NULL`, params: [] };
|
|
164
250
|
}
|
|
165
|
-
case '_contains':
|
|
166
|
-
|
|
167
|
-
case '
|
|
168
|
-
|
|
169
|
-
case '_ends_with':
|
|
170
|
-
return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
|
|
171
|
-
default:
|
|
172
|
-
throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
251
|
+
case '_contains': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
|
|
252
|
+
case '_starts_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
|
|
253
|
+
case '_ends_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
|
|
254
|
+
default: throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
173
255
|
}
|
|
174
256
|
}
|
|
175
257
|
|
|
176
258
|
function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
177
|
-
if (!filter || typeof filter !== 'object' || Array.isArray(filter))
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
if (depth > limits.maxFilterDepth) {
|
|
181
|
-
throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
|
|
182
|
-
}
|
|
183
|
-
|
|
259
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw queryError('Filter node must be an object', path);
|
|
260
|
+
if (depth > limits.maxFilterDepth) throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
|
|
184
261
|
state.nodes += 1;
|
|
185
|
-
if (state.nodes > limits.maxFilterNodes) {
|
|
186
|
-
throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
|
|
187
|
-
}
|
|
262
|
+
if (state.nodes > limits.maxFilterNodes) throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
|
|
188
263
|
|
|
189
264
|
const fragments = [];
|
|
190
265
|
const params = [];
|
|
191
|
-
|
|
192
266
|
for (const [key, value] of Object.entries(filter)) {
|
|
193
267
|
if (key === '_and' || key === '_or') {
|
|
194
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
const children = value.map((child, index) =>
|
|
198
|
-
compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
|
|
268
|
+
if (!Array.isArray(value) || value.length === 0) throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
|
|
269
|
+
const children = value.map((child, index) => compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
|
|
199
270
|
fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
|
|
200
271
|
for (const child of children) params.push(...child.params);
|
|
201
272
|
continue;
|
|
202
273
|
}
|
|
203
|
-
|
|
204
274
|
resolveField(schema, key, `${path}.${key}`);
|
|
205
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
206
|
-
throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
207
|
-
}
|
|
208
|
-
|
|
275
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
209
276
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
210
277
|
const fieldFragments = [];
|
|
211
278
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
212
|
-
const compiled = compileOperator(
|
|
213
|
-
fieldSql,
|
|
214
|
-
operator,
|
|
215
|
-
operatorValue,
|
|
216
|
-
`${path}.${key}.${operator}`,
|
|
217
|
-
limits,
|
|
218
|
-
);
|
|
279
|
+
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
|
|
219
280
|
fieldFragments.push(compiled.sql);
|
|
220
281
|
params.push(...compiled.params);
|
|
221
282
|
}
|
|
222
283
|
if (fieldFragments.length === 0) throw queryError('Field filter cannot be empty', `${path}.${key}`);
|
|
223
284
|
fragments.push(`(${fieldFragments.join(' AND ')})`);
|
|
224
285
|
}
|
|
225
|
-
|
|
226
286
|
if (fragments.length === 0) throw queryError('Filter cannot be empty', path);
|
|
227
287
|
return { sql: fragments.join(' AND '), params };
|
|
228
288
|
}
|