@crossfox/query-handler 0.1.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/LICENSE +118 -0
- package/README.md +62 -0
- package/dist/cache.d.ts +12 -0
- package/dist/cache.js +43 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.js +114 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +108 -0
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +37 -0
- package/dist/elysia.d.ts +33 -0
- package/dist/elysia.js +9 -0
- package/dist/env.d.ts +4 -0
- package/dist/env.js +30 -0
- package/dist/filter.d.ts +6 -0
- package/dist/filter.js +207 -0
- package/dist/helpers.d.ts +36 -0
- package/dist/helpers.js +262 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +66 -0
- package/dist/rate-limit.d.ts +2 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rateLimit.d.ts +8 -0
- package/dist/rateLimit.js +29 -0
- package/dist/relation.d.ts +2 -0
- package/dist/relation.js +64 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.js +32 -0
- package/dist/types.d.ts +267 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +11 -0
- package/dist/worker.d.ts +19 -0
- package/dist/worker.js +560 -0
- package/package.json +92 -0
package/dist/env.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Чтение env без зависимости от app config. */
|
|
2
|
+
export function envInt(raw, fallback) {
|
|
3
|
+
if (raw == null || raw === "")
|
|
4
|
+
return fallback;
|
|
5
|
+
const n = Number(raw);
|
|
6
|
+
return Number.isFinite(n) ? n : fallback;
|
|
7
|
+
}
|
|
8
|
+
export function envBool(raw, fallback) {
|
|
9
|
+
if (raw == null || raw === "")
|
|
10
|
+
return fallback;
|
|
11
|
+
const v = raw.trim().toLowerCase();
|
|
12
|
+
if (["1", "true", "yes", "on"].includes(v))
|
|
13
|
+
return true;
|
|
14
|
+
if (["0", "false", "no", "off"].includes(v))
|
|
15
|
+
return false;
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
function bunEnv(key) {
|
|
19
|
+
try {
|
|
20
|
+
// Bun.env в runtime; в чистом tsc — process.env
|
|
21
|
+
const b = globalThis.Bun;
|
|
22
|
+
return b?.env?.[key] ?? (typeof process !== "undefined" ? process.env[key] : undefined);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return typeof process !== "undefined" ? process.env[key] : undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function readEnv(key) {
|
|
29
|
+
return bunEnv(key);
|
|
30
|
+
}
|
package/dist/filter.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { QueryBuilder } from "@crossfox/db";
|
|
2
|
+
import type { QueryHandlerOptions, QueryHandlerContext } from "./types";
|
|
3
|
+
export interface ApplyFiltersResult {
|
|
4
|
+
appliedFilters: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export declare function applyFilters($q: QueryBuilder, filter: Record<string, unknown>, options: QueryHandlerOptions, ctx: Omit<QueryHandlerContext, 'filters'>, baseRef?: string): Promise<ApplyFiltersResult>;
|
package/dist/filter.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { toArray } from "./util";
|
|
2
|
+
import { getQueryHandlerDefaults } from "./config";
|
|
3
|
+
import { DANGEROUS_KEYS } from "./constants";
|
|
4
|
+
import { err, resolveFieldType, parseValue, resolveAllowedOperators, qualifyWhereColumn, isSchemaFactoryLike, } from "./helpers";
|
|
5
|
+
export async function applyFilters($q, filter, options, ctx, baseRef) {
|
|
6
|
+
const forcedKeys = new Set(Object.keys(options.filtersForced ?? {}));
|
|
7
|
+
const appliedFilters = {};
|
|
8
|
+
const emptyValues = new Set(options.limits?.emptyValues ?? [undefined, null, '']);
|
|
9
|
+
// ── filterForced ─────────────────────────────────────────────
|
|
10
|
+
if (forcedKeys.size) {
|
|
11
|
+
const fw = {};
|
|
12
|
+
for (const [k, v] of Object.entries(options.filtersForced))
|
|
13
|
+
fw[qualifyWhereColumn(k, baseRef)] = v;
|
|
14
|
+
$q.where(fw);
|
|
15
|
+
}
|
|
16
|
+
// ── filterRequired ───────────────────────────────────────────
|
|
17
|
+
if (options.filtersRequired) {
|
|
18
|
+
const rw = {};
|
|
19
|
+
for (const [k, v] of Object.entries(options.filtersRequired))
|
|
20
|
+
rw[qualifyWhereColumn(k, baseRef)] = v;
|
|
21
|
+
$q.where(rw);
|
|
22
|
+
}
|
|
23
|
+
const filterKeys = Object.keys(filter);
|
|
24
|
+
const allowedFilters = options.filters ?? {};
|
|
25
|
+
const allowedKeys = Object.keys(allowedFilters);
|
|
26
|
+
// ── requiredFilters ───────────────────────────────────────────
|
|
27
|
+
if (options.requiredFilters?.length) {
|
|
28
|
+
for (const rf of options.requiredFilters) {
|
|
29
|
+
if (!filterKeys.includes(rf) || emptyValues.has(filter[rf]))
|
|
30
|
+
err(`filter[${rf}] is required`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ── strictFilters ─────────────────────────────────────────────
|
|
34
|
+
const { isDev, logger } = getQueryHandlerDefaults().runtime;
|
|
35
|
+
if (filterKeys.length && !allowedKeys.length) {
|
|
36
|
+
if (options.strictFilters)
|
|
37
|
+
err('Filters are not supported for this endpoint');
|
|
38
|
+
else if (isDev)
|
|
39
|
+
logger?.info?.('queryHandler', 'filter keys ignored (no whitelist): ' + filterKeys.join(', '));
|
|
40
|
+
return { appliedFilters };
|
|
41
|
+
}
|
|
42
|
+
if (!allowedKeys.length || !filterKeys.length) {
|
|
43
|
+
// Применяем filterDefault если клиент ничего не передал
|
|
44
|
+
if (options.filtersDefault) {
|
|
45
|
+
const dw = {};
|
|
46
|
+
for (const [k, v] of Object.entries(options.filtersDefault))
|
|
47
|
+
dw[qualifyWhereColumn(k, baseRef)] = v;
|
|
48
|
+
if (Object.keys(dw).length)
|
|
49
|
+
$q.where(dw);
|
|
50
|
+
}
|
|
51
|
+
return { appliedFilters };
|
|
52
|
+
}
|
|
53
|
+
// ── Обработка фильтров ────────────────────────────────────────
|
|
54
|
+
const where = {};
|
|
55
|
+
for (const f of filterKeys) {
|
|
56
|
+
if (!Object.hasOwn(filter, f))
|
|
57
|
+
continue;
|
|
58
|
+
if (DANGEROUS_KEYS.has(f) || forcedKeys.has(f))
|
|
59
|
+
err('Invalid filter key');
|
|
60
|
+
if (!Object.prototype.hasOwnProperty.call(allowedFilters, f)) {
|
|
61
|
+
if (options.strictFilters)
|
|
62
|
+
err('Filter not found: ' + f);
|
|
63
|
+
if (isDev)
|
|
64
|
+
logger?.warn?.('queryHandler', `Unknown filter key ignored: "${f}"`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const fieldCfg = allowedFilters[f];
|
|
68
|
+
const fieldType = resolveFieldType(fieldCfg, f);
|
|
69
|
+
const fullCfg = (typeof fieldCfg === 'object')
|
|
70
|
+
? fieldCfg
|
|
71
|
+
: null;
|
|
72
|
+
// ── deprecated ────────────────────────────────────────────
|
|
73
|
+
if (fullCfg?.deprecated && isDev)
|
|
74
|
+
logger?.warn?.('queryHandler', `Filter "${f}" is deprecated: ${fullCfg.deprecated}`);
|
|
75
|
+
// ── enable → 403 ──────────────────────────────────────────
|
|
76
|
+
if (fullCfg?.enable !== undefined) {
|
|
77
|
+
const enabled = typeof fullCfg.enable === 'function'
|
|
78
|
+
? await fullCfg.enable({ ...ctx, filters: appliedFilters })
|
|
79
|
+
: fullCfg.enable;
|
|
80
|
+
if (!enabled)
|
|
81
|
+
err(`Filter "${f}" is not available`, 403);
|
|
82
|
+
}
|
|
83
|
+
// ── функция-обработчик ────────────────────────────────────
|
|
84
|
+
if (typeof fieldCfg === 'function') {
|
|
85
|
+
await fieldCfg($q, filter[f]);
|
|
86
|
+
appliedFilters[f] = filter[f];
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let val = filter[f];
|
|
90
|
+
// ── sanitize ──────────────────────────────────────────────
|
|
91
|
+
if (fullCfg?.sanitize)
|
|
92
|
+
val = fullCfg.sanitize(val);
|
|
93
|
+
// ── validate ──────────────────────────────────────────────
|
|
94
|
+
if (fullCfg?.validate)
|
|
95
|
+
await fullCfg.validate(val, { ...ctx, filters: appliedFilters });
|
|
96
|
+
// ── dependsOn ─────────────────────────────────────────────
|
|
97
|
+
if (fullCfg?.dependsOn) {
|
|
98
|
+
for (const dep of toArray(fullCfg.dependsOn)) {
|
|
99
|
+
if (!filterKeys.includes(dep) || emptyValues.has(filter[dep]))
|
|
100
|
+
err(`Filter "${f}" requires filter "${dep}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ── conflictsWith ─────────────────────────────────────────
|
|
104
|
+
if (fullCfg?.conflictsWith) {
|
|
105
|
+
for (const conflict of toArray(fullCfg.conflictsWith)) {
|
|
106
|
+
if (filterKeys.includes(conflict) && !emptyValues.has(filter[conflict]))
|
|
107
|
+
err(`Filter "${f}" conflicts with filter "${conflict}"`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ── enum ──────────────────────────────────────────────────
|
|
111
|
+
if (fullCfg?.enum !== undefined) {
|
|
112
|
+
await assertEnum(fullCfg.enum, val, f);
|
|
113
|
+
}
|
|
114
|
+
const colName = fullCfg?.column ?? f;
|
|
115
|
+
const allowedOps = resolveAllowedOperators(options.limits, fieldType);
|
|
116
|
+
const qCol = qualifyWhereColumn(colName, baseRef);
|
|
117
|
+
// ── operator объект ───────────────────────────────────────
|
|
118
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
119
|
+
const opKeys = Object.keys(val);
|
|
120
|
+
if (!opKeys.length)
|
|
121
|
+
err(`Filter "${f}": operator object is empty`);
|
|
122
|
+
if (opKeys.length > 1)
|
|
123
|
+
err(`Filter "${f}": only one operator allowed`);
|
|
124
|
+
const op = opKeys[0].toLowerCase();
|
|
125
|
+
if (!allowedOps.has(op))
|
|
126
|
+
err(`Operator "${op}" is not allowed`);
|
|
127
|
+
if (op === 'between') {
|
|
128
|
+
const parts = String(val[op]).split(',');
|
|
129
|
+
if (parts.length < 2)
|
|
130
|
+
err(`Filter "${f}": between requires two comma-separated values`);
|
|
131
|
+
where[qCol] = {
|
|
132
|
+
gte: parseValue(parts[0].trim(), fieldType, f),
|
|
133
|
+
lte: parseValue(parts[1].trim(), fieldType, f),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
else if (op === 'in' || op === 'nin') {
|
|
137
|
+
where[qCol] = {
|
|
138
|
+
[op]: String(val[op]).split(',')
|
|
139
|
+
.map(v => parseValue(v.trim(), fieldType, f)),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
else if (op === 'null') {
|
|
143
|
+
where[qCol] = null;
|
|
144
|
+
}
|
|
145
|
+
else if (op === 'nnull') {
|
|
146
|
+
where[qCol] = { nnull: true };
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
where[qCol] = { [op]: parseValue(val[op], fieldType, f) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// ── multiple ──────────────────────────────────────────
|
|
154
|
+
if (fullCfg?.multiple && typeof val === 'string' && val.includes(',')) {
|
|
155
|
+
where[qCol] = {
|
|
156
|
+
in: val.split(',').map(v => parseValue(v.trim(), fieldType, f)),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
let parsed = parseValue(val, fieldType, f);
|
|
161
|
+
if (fullCfg?.transform)
|
|
162
|
+
parsed = fullCfg.transform(parsed);
|
|
163
|
+
where[qCol] = parsed;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
appliedFilters[f] = where[qCol];
|
|
167
|
+
}
|
|
168
|
+
// ── filterDefault — только если ключ не передан ───────────────
|
|
169
|
+
if (options.filtersDefault) {
|
|
170
|
+
for (const [k, v] of Object.entries(options.filtersDefault)) {
|
|
171
|
+
if (!filterKeys.includes(k)) {
|
|
172
|
+
where[qualifyWhereColumn(k, baseRef)] = v;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (Object.keys(where).length)
|
|
177
|
+
$q.where(where);
|
|
178
|
+
return { appliedFilters };
|
|
179
|
+
}
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
// Enum проверка
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
async function assertEnum(enumCfg, val, field) {
|
|
184
|
+
if (Array.isArray(enumCfg)) {
|
|
185
|
+
if (!enumCfg.includes(val))
|
|
186
|
+
err(`Filter "${field}": invalid value "${val}"`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (typeof enumCfg === 'function' && !isSchemaFactoryLike(enumCfg)) {
|
|
190
|
+
await enumCfg(val);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (isSchemaFactoryLike(enumCfg)) {
|
|
194
|
+
const model = enumCfg;
|
|
195
|
+
const pk = model.primaryKey ?? 'id';
|
|
196
|
+
const row = await model().select(['1']).where({ [pk]: val }).first();
|
|
197
|
+
if (!row)
|
|
198
|
+
err(`Filter "${field}": value does not exist`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (typeof enumCfg === 'object' && 'model' in enumCfg) {
|
|
202
|
+
const { model, field: enumField } = enumCfg;
|
|
203
|
+
const row = await model().select(['1']).where({ [enumField]: val }).first();
|
|
204
|
+
if (!row)
|
|
205
|
+
err(`Filter "${field}": value does not exist`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FilterFieldType, FilterFieldConfig, FilterOperator, FieldEntry, QueryHandlerOptions, ScopeHandler } from "./types";
|
|
2
|
+
export declare const err: (msg: string, code?: number) => never;
|
|
3
|
+
/** Синхронный или async массив → Promise<T[]> (для присваивания в handler.items). */
|
|
4
|
+
export declare function resolveItems<T>(items: T[] | Promise<T[]>): Promise<T[]>;
|
|
5
|
+
/** `?count=0` — выключить total; `?count=1` — явно включить. */
|
|
6
|
+
export declare function resolveCountFromQuery(count: unknown): boolean | undefined;
|
|
7
|
+
/** Подсчёт total: по умолчанию включён; отключается `withTotal: false` или `?count=0`. */
|
|
8
|
+
export declare function resolveWithTotal(optionValue: boolean | undefined, globalCount: boolean, queryCount: unknown): boolean;
|
|
9
|
+
/** COUNT при cursor: по умолчанию включён; `allowCount: false` или `?count=0` отключают. */
|
|
10
|
+
export declare function resolveCursorAllowCount(cfg: {
|
|
11
|
+
allowCount?: boolean;
|
|
12
|
+
}, queryCount: unknown): boolean;
|
|
13
|
+
export declare function inferTypeByFieldName(name: string): FilterFieldType | undefined;
|
|
14
|
+
export declare function resolveFieldType(cfg: FilterFieldConfig | undefined, fieldName: string): FilterFieldType | undefined;
|
|
15
|
+
export declare function parseValue(raw: unknown, type: FilterFieldType | undefined, field: string, maxStringLen?: number): unknown;
|
|
16
|
+
export declare function resolveAllowedOperators(limits: QueryHandlerOptions['limits'], fieldType: FilterFieldType | undefined): Set<FilterOperator>;
|
|
17
|
+
export declare const sortDecode: (c: string) => [string, "ASC" | "DESC"];
|
|
18
|
+
export declare const sortColumnName: (token: string) => string;
|
|
19
|
+
export declare const isSortAllowed: (col: string, whitelist: string[]) => boolean;
|
|
20
|
+
export declare function assertSafeColumnRef(raw: string, label: string): string;
|
|
21
|
+
export declare function qualifyWhereColumn(column: string, baseRef?: string): string;
|
|
22
|
+
export declare function qualifySelectField(column: string, baseRef?: string): string;
|
|
23
|
+
/** Имя поля для проверки whitelist: строка, FieldMarker (.column) или `{alias: expr}`. */
|
|
24
|
+
export declare function fieldEntryName(entry: FieldEntry): string;
|
|
25
|
+
export declare function resolveFieldEntry(entry: FieldEntry, baseRef?: string): string;
|
|
26
|
+
export declare function parsePositiveInt(raw: unknown, label: string): number | null;
|
|
27
|
+
export declare function mergeOptions(base: QueryHandlerOptions, patch: Partial<QueryHandlerOptions>): QueryHandlerOptions;
|
|
28
|
+
export declare function assertKnownOptionKeys(opts: object): void;
|
|
29
|
+
export declare function isSchemaFactoryLike(x: unknown): boolean;
|
|
30
|
+
export type QueryHandlerInstance = {
|
|
31
|
+
run(): Promise<Record<string, unknown>>;
|
|
32
|
+
};
|
|
33
|
+
export declare function isQueryHandler(value: unknown): value is QueryHandlerInstance;
|
|
34
|
+
export declare function resolveNameList(clientParam: unknown, defaultParam?: string | string[] | ReadonlyArray<string>): string[];
|
|
35
|
+
export declare function normalizeRelationMap(relations: QueryHandlerOptions['relations']): Map<string, import("./types").RelationConfig>;
|
|
36
|
+
export declare function normalizeScopeMap(scopes: QueryHandlerOptions['scopes'] | undefined): Map<string, ScopeHandler>;
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { PARSERS, TYPE_BY_PATTERN, ALL_FILTER_OPERATORS, DEFAULT_OPERATOR_SETS, SIMPLE_ID_RE, ARRAY_MERGE_KEYS, OBJECT_MERGE_KEYS, KNOWN_OPTION_KEYS, QUERY_HANDLER_BRAND, } from "./constants";
|
|
2
|
+
import { isFieldMarker } from "@crossfox/db/markers";
|
|
3
|
+
import { error } from "@crossfox/db";
|
|
4
|
+
import { toArray } from "./util";
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
// Ошибки
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
export const err = (msg, code = 422) => error(code, 'QueryHandler: ' + msg);
|
|
9
|
+
/** Синхронный или async массив → Promise<T[]> (для присваивания в handler.items). */
|
|
10
|
+
export function resolveItems(items) {
|
|
11
|
+
return Promise.resolve(items);
|
|
12
|
+
}
|
|
13
|
+
/** `?count=0` — выключить total; `?count=1` — явно включить. */
|
|
14
|
+
export function resolveCountFromQuery(count) {
|
|
15
|
+
if (count === 0 || count === '0' || count === 'false' || count === false)
|
|
16
|
+
return false;
|
|
17
|
+
if (count === 1 || count === '1' || count === 'true' || count === true)
|
|
18
|
+
return true;
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Подсчёт total: по умолчанию включён; отключается `withTotal: false` или `?count=0`. */
|
|
22
|
+
export function resolveWithTotal(optionValue, globalCount, queryCount) {
|
|
23
|
+
return resolveCountFromQuery(queryCount) ?? optionValue ?? globalCount;
|
|
24
|
+
}
|
|
25
|
+
/** COUNT при cursor: по умолчанию включён; `allowCount: false` или `?count=0` отключают. */
|
|
26
|
+
export function resolveCursorAllowCount(cfg, queryCount) {
|
|
27
|
+
// Приоритет: явный false в конфиге -> query параметр -> дефолтное true
|
|
28
|
+
if (cfg.allowCount === false)
|
|
29
|
+
return false;
|
|
30
|
+
return resolveCountFromQuery(queryCount) ?? true;
|
|
31
|
+
}
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Типы фильтров
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
export function inferTypeByFieldName(name) {
|
|
36
|
+
for (const [pattern, type] of TYPE_BY_PATTERN) {
|
|
37
|
+
if (pattern.test(name))
|
|
38
|
+
return type;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
export function resolveFieldType(cfg, fieldName) {
|
|
43
|
+
if (!cfg || cfg === true || typeof cfg === 'function')
|
|
44
|
+
return inferTypeByFieldName(fieldName);
|
|
45
|
+
if (typeof cfg === 'string')
|
|
46
|
+
return cfg;
|
|
47
|
+
return cfg.type ?? inferTypeByFieldName(fieldName);
|
|
48
|
+
}
|
|
49
|
+
export function parseValue(raw, type, field, maxStringLen = 500) {
|
|
50
|
+
if (type === undefined)
|
|
51
|
+
return raw;
|
|
52
|
+
const str = String(raw);
|
|
53
|
+
if (type === 'string' && str.length > maxStringLen)
|
|
54
|
+
err(`Filter "${field}": value is too long (max ${maxStringLen} chars)`);
|
|
55
|
+
try {
|
|
56
|
+
return PARSERS[type](str, field);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
err(e.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function resolveAllowedOperators(limits, fieldType) {
|
|
63
|
+
const custom = limits?.allowedOperators;
|
|
64
|
+
if (custom?.length)
|
|
65
|
+
return new Set(custom);
|
|
66
|
+
if (fieldType)
|
|
67
|
+
return DEFAULT_OPERATOR_SETS[fieldType];
|
|
68
|
+
return ALL_FILTER_OPERATORS;
|
|
69
|
+
}
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Сортировка
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
export const sortDecode = (c) => {
|
|
74
|
+
const isAsc = c.startsWith('!');
|
|
75
|
+
return [isAsc ? c.slice(1) : c, isAsc ? 'ASC' : 'DESC'];
|
|
76
|
+
};
|
|
77
|
+
export const sortColumnName = (token) => token.startsWith('!') ? token.slice(1) : token;
|
|
78
|
+
export const isSortAllowed = (col, whitelist) => {
|
|
79
|
+
if (!whitelist.length)
|
|
80
|
+
return true;
|
|
81
|
+
const name = sortColumnName(col);
|
|
82
|
+
return whitelist.some(w => sortColumnName(w) === name);
|
|
83
|
+
};
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
// Колонки / SQL безопасность
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
export function assertSafeColumnRef(raw, label) {
|
|
88
|
+
const s = raw.trim();
|
|
89
|
+
if (s === '*')
|
|
90
|
+
return '*';
|
|
91
|
+
const dot = s.indexOf('.');
|
|
92
|
+
if (dot === -1) {
|
|
93
|
+
if (!SIMPLE_ID_RE.test(s))
|
|
94
|
+
err(`${label}: invalid column "${raw}"`, 422);
|
|
95
|
+
return s;
|
|
96
|
+
}
|
|
97
|
+
if (dot !== s.lastIndexOf('.'))
|
|
98
|
+
err(`${label}: only alias.column allowed: "${raw}"`, 422);
|
|
99
|
+
const a = s.slice(0, dot), b = s.slice(dot + 1);
|
|
100
|
+
if (!SIMPLE_ID_RE.test(a) || !SIMPLE_ID_RE.test(b))
|
|
101
|
+
err(`${label}: invalid qualified column "${raw}"`, 422);
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
export function qualifyWhereColumn(column, baseRef) {
|
|
105
|
+
const safe = assertSafeColumnRef(column, 'Column');
|
|
106
|
+
if (!baseRef || safe === '*' || safe.includes('.'))
|
|
107
|
+
return safe;
|
|
108
|
+
return `${baseRef}.${safe}`;
|
|
109
|
+
}
|
|
110
|
+
export function qualifySelectField(column, baseRef) {
|
|
111
|
+
if (!baseRef)
|
|
112
|
+
return column;
|
|
113
|
+
if (column === '*' || !SIMPLE_ID_RE.test(column))
|
|
114
|
+
return column;
|
|
115
|
+
return `${baseRef}.${column}`;
|
|
116
|
+
}
|
|
117
|
+
/** Имя поля для проверки whitelist: строка, FieldMarker (.column) или `{alias: expr}`. */
|
|
118
|
+
export function fieldEntryName(entry) {
|
|
119
|
+
if (typeof entry === 'string')
|
|
120
|
+
return entry;
|
|
121
|
+
if (isFieldMarker(entry))
|
|
122
|
+
return entry.column;
|
|
123
|
+
return Object.keys(entry)[0];
|
|
124
|
+
}
|
|
125
|
+
export function resolveFieldEntry(entry, baseRef) {
|
|
126
|
+
if (typeof entry === 'string')
|
|
127
|
+
return qualifySelectField(entry, baseRef);
|
|
128
|
+
// FieldMarker (model.field()/fields()) уже несёт алиас в toString → alias.column.
|
|
129
|
+
if (isFieldMarker(entry))
|
|
130
|
+
return String(entry);
|
|
131
|
+
const [alias, expr] = Object.entries(entry)[0];
|
|
132
|
+
return `${expr} AS ${alias}`;
|
|
133
|
+
}
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
// Парсинг числа из query
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
export function parsePositiveInt(raw, label) {
|
|
138
|
+
if (raw === undefined || raw === null || raw === '')
|
|
139
|
+
return null;
|
|
140
|
+
if (typeof raw === 'number') {
|
|
141
|
+
if (!Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1)
|
|
142
|
+
err(`${label} must be a positive integer`);
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
145
|
+
const s = String(raw).trim();
|
|
146
|
+
if (!/^\d+$/.test(s))
|
|
147
|
+
err(`${label} must be a positive integer`);
|
|
148
|
+
const n = Number(s);
|
|
149
|
+
if (n < 1)
|
|
150
|
+
err(`${label} must be a positive integer`);
|
|
151
|
+
return n;
|
|
152
|
+
}
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
// Merge опций
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
export function mergeOptions(base, patch) {
|
|
157
|
+
const result = { ...base };
|
|
158
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
159
|
+
if (val === false && ARRAY_MERGE_KEYS.has(key)) {
|
|
160
|
+
result[key] = [];
|
|
161
|
+
}
|
|
162
|
+
else if (val === false && OBJECT_MERGE_KEYS.has(key)) {
|
|
163
|
+
result[key] = {};
|
|
164
|
+
}
|
|
165
|
+
else if (ARRAY_MERGE_KEYS.has(key) &&
|
|
166
|
+
Array.isArray(val) &&
|
|
167
|
+
Array.isArray(result[key])) {
|
|
168
|
+
result[key] = [...result[key], ...val];
|
|
169
|
+
}
|
|
170
|
+
else if (OBJECT_MERGE_KEYS.has(key) &&
|
|
171
|
+
val && typeof val === 'object' && !Array.isArray(val)) {
|
|
172
|
+
result[key] = { ...result[key], ...val };
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
result[key] = val;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
// Валидация ключей опций
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
export function assertKnownOptionKeys(opts) {
|
|
184
|
+
if (!opts || typeof opts !== 'object' || Array.isArray(opts))
|
|
185
|
+
return;
|
|
186
|
+
for (const key of Object.keys(opts)) {
|
|
187
|
+
if (key === 'model')
|
|
188
|
+
err('Передайте модель первым аргументом: queryHandler(model, query, options?)');
|
|
189
|
+
if (!KNOWN_OPTION_KEYS.has(key))
|
|
190
|
+
err(`Неизвестная опция: "${key}"`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
// SchemaFactory guard
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
export function isSchemaFactoryLike(x) {
|
|
197
|
+
return typeof x === 'function' && 'table' in x;
|
|
198
|
+
}
|
|
199
|
+
export function isQueryHandler(value) {
|
|
200
|
+
return typeof value === 'object'
|
|
201
|
+
&& value !== null
|
|
202
|
+
&& QUERY_HANDLER_BRAND in value
|
|
203
|
+
&& typeof value.run === 'function';
|
|
204
|
+
}
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Списки имён (relation / scope из query или *Default)
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
export function resolveNameList(clientParam, defaultParam) {
|
|
209
|
+
const fromClient = toArray(clientParam)
|
|
210
|
+
// @ts-ignore
|
|
211
|
+
.flatMap((s) => String(s).split(','))
|
|
212
|
+
.map((s) => s.trim())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
if (fromClient.length)
|
|
215
|
+
return fromClient;
|
|
216
|
+
if (!defaultParam)
|
|
217
|
+
return [];
|
|
218
|
+
return toArray(defaultParam)
|
|
219
|
+
// @ts-ignore
|
|
220
|
+
.flatMap((s) => String(s).split(','))
|
|
221
|
+
.map((s) => s.trim())
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
}
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
// Нормализация relation map
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
export function normalizeRelationMap(relations) {
|
|
228
|
+
const out = new Map();
|
|
229
|
+
if (!relations)
|
|
230
|
+
return out;
|
|
231
|
+
if (Array.isArray(relations)) {
|
|
232
|
+
for (const name of relations) {
|
|
233
|
+
if (typeof name === 'string' && name)
|
|
234
|
+
out.set(name, { with: name });
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
for (const [name, cfg] of Object.entries(relations)) {
|
|
239
|
+
if (cfg !== false)
|
|
240
|
+
out.set(name, cfg);
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
// Нормализация scope map
|
|
246
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
export function normalizeScopeMap(scopes) {
|
|
248
|
+
const out = new Map();
|
|
249
|
+
if (!scopes)
|
|
250
|
+
return out;
|
|
251
|
+
for (const [name, cfg] of Object.entries(scopes)) {
|
|
252
|
+
if (cfg === false)
|
|
253
|
+
continue;
|
|
254
|
+
if (cfg === true) {
|
|
255
|
+
out.set(name, (q) => { q.scope(name); });
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
out.set(name, cfg);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { configureQueryHandler, getQueryHandlerGlobalConfig, getQueryHandlerDefaults, resetQueryHandlerConfig, type QueryHandlerConfigureInput, type QueryHandlerGlobalConfig } from "./config";
|
|
2
|
+
import { queryHandlerWorker } from "./worker";
|
|
3
|
+
import type { SchemaFactoryLike, QueryHandlerOptions, QueryHandlerCallOptions, QueryHandlerBuilderOptions, QueryHandlerQueryArg } from "./types";
|
|
4
|
+
export { configureQueryHandler, getQueryHandlerGlobalConfig, getQueryHandlerDefaults, resetQueryHandlerConfig };
|
|
5
|
+
export type { QueryHandlerConfigureInput, QueryHandlerGlobalConfig };
|
|
6
|
+
export { purgeQueryHandlerCache, invalidateQueryHandlerCache } from "./cache";
|
|
7
|
+
export { isQueryHandler } from "./helpers";
|
|
8
|
+
export type { QueryHandlerInstance } from "./helpers";
|
|
9
|
+
export { purgeRateLimitBuckets } from "./rateLimit";
|
|
10
|
+
export { QUERY_HANDLER_BRAND } from "./constants";
|
|
11
|
+
export type { SchemaFactoryLike, InferSchemaRow, QueryHandlerOptions, QueryHandlerCallOptions, QueryHandlerBuilderOptions, QueryHandlerQueryArg, QueryHandlerUrlQuery, QueryHandlerContext, WorkerOptions, FieldEntry, FilterFieldType, FilterFieldConfig, FilterFieldFullConfig, FilterOperator, FilterEnumConfig, FilterDefMap, RelationConfig, RelationMapInput, ExtendHooks, ScopeHandler, ScopeShorthand, ScopeDefMap, InferSchemaScopeNames, ApplyEntry, ApplyObject, ApplyFn, CursorPaginateConfig, ChunkConfig, ChunkMeta, RandomizeConfig, RateLimitConfig, QueryHandlerFilter, QueryHandlerFilterForced, QueryHandlerInitial, ForcedWhereMap, } from "./types";
|
|
12
|
+
/**
|
|
13
|
+
* Рекомендуемый вызов: модель первым аргументом — TS выводит ключи строки.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* queryHandler(modelOrder, query, { filtersForced: { deleted_at: null } })
|
|
17
|
+
*/
|
|
18
|
+
export declare function queryHandler<M extends SchemaFactoryLike>(model: M, query: QueryHandlerQueryArg, options?: QueryHandlerCallOptions<M>): ReturnType<typeof queryHandlerWorker>;
|
|
19
|
+
/** Готовый QueryBuilder вместо model */
|
|
20
|
+
export declare function queryHandler(query: QueryHandlerQueryArg, options: QueryHandlerBuilderOptions): ReturnType<typeof queryHandlerWorker>;
|
|
21
|
+
/**
|
|
22
|
+
* Фабрика с привязанной моделью — IDE стабильно выводит ключи строки.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const qhOrder = queryHandlerFor(modelOrder)
|
|
26
|
+
* qhOrder(query, { filtersForced: { status: 'active' } })
|
|
27
|
+
*/
|
|
28
|
+
export declare function queryHandlerFor<M extends SchemaFactoryLike>(model: M): (query: QueryHandlerQueryArg, options?: QueryHandlerCallOptions<M>) => ReturnType<typeof queryHandlerWorker>;
|
|
29
|
+
/**
|
|
30
|
+
* Создать инстанцию с фиксированными настройками.
|
|
31
|
+
* Приоритет: глобальный конфиг → инстанция → локальные опции.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const adminQH = createQueryHandler({ maxLimit: 1000, strictFilters: false })
|
|
35
|
+
* adminQH(modelOrder, query, { filters: { status: 'string' } })
|
|
36
|
+
*
|
|
37
|
+
* const qhOrder = adminQH.for(modelOrder)
|
|
38
|
+
* qhOrder(query, options)
|
|
39
|
+
*/
|
|
40
|
+
export declare function createQueryHandler(instanceDefaults: Partial<QueryHandlerOptions>): {
|
|
41
|
+
<M extends SchemaFactoryLike>(model: M, query: QueryHandlerQueryArg, options?: QueryHandlerCallOptions<M>): ReturnType<typeof queryHandlerWorker>;
|
|
42
|
+
for<M extends SchemaFactoryLike>(model: M): (query: QueryHandlerQueryArg, options?: QueryHandlerCallOptions<M>) => import("./worker").QueryHandlerWorkerResult;
|
|
43
|
+
};
|
|
44
|
+
export default queryHandler;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { configureQueryHandler, getQueryHandlerGlobalConfig, getQueryHandlerDefaults, resetQueryHandlerConfig, } from "./config";
|
|
2
|
+
import { mergeOptions, assertKnownOptionKeys, isSchemaFactoryLike } from "./helpers";
|
|
3
|
+
import { queryHandlerWorker } from "./worker";
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// Re-exports
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
export { configureQueryHandler, getQueryHandlerGlobalConfig, getQueryHandlerDefaults, resetQueryHandlerConfig };
|
|
8
|
+
export { purgeQueryHandlerCache, invalidateQueryHandlerCache } from "./cache";
|
|
9
|
+
export { isQueryHandler } from "./helpers";
|
|
10
|
+
export { purgeRateLimitBuckets } from "./rateLimit";
|
|
11
|
+
export { QUERY_HANDLER_BRAND } from "./constants";
|
|
12
|
+
export function queryHandler(arg1, arg2, arg3) {
|
|
13
|
+
if (arguments.length >= 3 && !isSchemaFactoryLike(arg1)) {
|
|
14
|
+
throw new Error("QueryHandler: при вызове с тремя аргументами первым должна быть фабрика модели (createSchema). " +
|
|
15
|
+
"Варианты: queryHandler(model, query, options?) или queryHandler(query, { query: builder, … }).");
|
|
16
|
+
}
|
|
17
|
+
if (isSchemaFactoryLike(arg1)) {
|
|
18
|
+
const opts = arg3 !== undefined && typeof arg3 === 'object' && arg3 !== null
|
|
19
|
+
? { ...arg3 } : {};
|
|
20
|
+
assertKnownOptionKeys(opts);
|
|
21
|
+
return queryHandlerWorker(arg2, { ...opts, model: arg1 });
|
|
22
|
+
}
|
|
23
|
+
const opts = (arg2 ?? {});
|
|
24
|
+
assertKnownOptionKeys(opts);
|
|
25
|
+
return queryHandlerWorker(arg1, opts);
|
|
26
|
+
}
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// queryHandlerFor — привязанная модель
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Фабрика с привязанной моделью — IDE стабильно выводит ключи строки.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const qhOrder = queryHandlerFor(modelOrder)
|
|
35
|
+
* qhOrder(query, { filtersForced: { status: 'active' } })
|
|
36
|
+
*/
|
|
37
|
+
export function queryHandlerFor(model) {
|
|
38
|
+
return (query, options) => {
|
|
39
|
+
assertKnownOptionKeys(options ?? {});
|
|
40
|
+
return queryHandlerWorker(query, { ...options, model });
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// createQueryHandler — инстанция со своими дефолтами
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Создать инстанцию с фиксированными настройками.
|
|
48
|
+
* Приоритет: глобальный конфиг → инстанция → локальные опции.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const adminQH = createQueryHandler({ maxLimit: 1000, strictFilters: false })
|
|
52
|
+
* adminQH(modelOrder, query, { filters: { status: 'string' } })
|
|
53
|
+
*
|
|
54
|
+
* const qhOrder = adminQH.for(modelOrder)
|
|
55
|
+
* qhOrder(query, options)
|
|
56
|
+
*/
|
|
57
|
+
export function createQueryHandler(instanceDefaults) {
|
|
58
|
+
function instanceHandler(model, query, options) {
|
|
59
|
+
const merged = mergeOptions(instanceDefaults, options ?? {});
|
|
60
|
+
assertKnownOptionKeys(merged);
|
|
61
|
+
return queryHandlerWorker(query, { ...merged, model });
|
|
62
|
+
}
|
|
63
|
+
instanceHandler.for = (model) => (query, options) => instanceHandler(model, query, options);
|
|
64
|
+
return instanceHandler;
|
|
65
|
+
}
|
|
66
|
+
export default queryHandler;
|