@usequark/quark-core 2.5.4
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/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Pagination Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides offset-based pagination helpers that integrate with Prisma's
|
|
5
|
+
* skip/take API and the project's ValidationError convention.
|
|
6
|
+
*
|
|
7
|
+
* Usage in a route handler:
|
|
8
|
+
*
|
|
9
|
+
* import { parsePaginationQuery } from "@techstream/quark-core";
|
|
10
|
+
*
|
|
11
|
+
* export async function GET(request) {
|
|
12
|
+
* const { searchParams } = new URL(request.url);
|
|
13
|
+
* const { skip, take, meta } = parsePaginationQuery(searchParams);
|
|
14
|
+
* const [records, total] = await Promise.all([
|
|
15
|
+
* prisma.post.findMany({ skip, take }),
|
|
16
|
+
* prisma.post.count(),
|
|
17
|
+
* ]);
|
|
18
|
+
* return NextResponse.json({ data: records, pagination: meta(total) });
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { ValidationError } from "./errors.js";
|
|
24
|
+
|
|
25
|
+
/** Default maximum page size. Can be overridden per-call via options. */
|
|
26
|
+
const DEFAULT_MAX_LIMIT = 100;
|
|
27
|
+
|
|
28
|
+
/** Default page size when ?limit is not provided. */
|
|
29
|
+
const DEFAULT_LIMIT = 20;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a Zod schema for pagination query params.
|
|
33
|
+
* @param {number} maxLimit
|
|
34
|
+
* @returns {import("zod").ZodObject<any>}
|
|
35
|
+
*/
|
|
36
|
+
function buildSchema(maxLimit) {
|
|
37
|
+
return z.object({
|
|
38
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
39
|
+
limit: z.coerce.number().int().min(1).max(maxLimit).default(DEFAULT_LIMIT),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse and validate raw pagination params from a URL search params object,
|
|
45
|
+
* a plain key/value object, or any iterable of [key, value] pairs.
|
|
46
|
+
*
|
|
47
|
+
* Throws ValidationError on invalid input so the existing `handleError`
|
|
48
|
+
* in route handlers catches it and returns a 422 automatically.
|
|
49
|
+
*
|
|
50
|
+
* @param {URLSearchParams | Record<string, string | undefined> | Iterable<[string, string]>} params
|
|
51
|
+
* @param {{ maxLimit?: number }} [options]
|
|
52
|
+
* @returns {{ page: number, limit: number }}
|
|
53
|
+
*/
|
|
54
|
+
export function parsePagination(params, options = {}) {
|
|
55
|
+
const maxLimit = options.maxLimit ?? DEFAULT_MAX_LIMIT;
|
|
56
|
+
const schema = buildSchema(maxLimit);
|
|
57
|
+
|
|
58
|
+
// Normalise to a plain object regardless of input type
|
|
59
|
+
let raw;
|
|
60
|
+
if (params instanceof URLSearchParams) {
|
|
61
|
+
raw = { page: params.get("page"), limit: params.get("limit") };
|
|
62
|
+
} else if (
|
|
63
|
+
typeof params === "object" &&
|
|
64
|
+
params !== null &&
|
|
65
|
+
typeof params[Symbol.iterator] === "function"
|
|
66
|
+
) {
|
|
67
|
+
raw = Object.fromEntries(params);
|
|
68
|
+
} else {
|
|
69
|
+
raw = params ?? {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Strip null/undefined keys so Zod defaults kick in
|
|
73
|
+
const cleaned = Object.fromEntries(
|
|
74
|
+
Object.entries(raw).filter(([, v]) => v != null),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const result = schema.safeParse(cleaned);
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
throw new ValidationError(result.error.flatten());
|
|
80
|
+
}
|
|
81
|
+
return result.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Convert page + limit into Prisma's skip/take arguments.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ page: number, limit: number }} pagination
|
|
88
|
+
* @returns {{ skip: number, take: number }}
|
|
89
|
+
*/
|
|
90
|
+
export function paginationToSkip({ page, limit }) {
|
|
91
|
+
return {
|
|
92
|
+
skip: (page - 1) * limit,
|
|
93
|
+
take: limit,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the pagination metadata envelope returned in API responses.
|
|
99
|
+
*
|
|
100
|
+
* @param {{ page: number, limit: number, total: number }} params
|
|
101
|
+
* @returns {{ total: number, page: number, limit: number, totalPages: number, hasNext: boolean, hasPrev: boolean }}
|
|
102
|
+
*/
|
|
103
|
+
export function paginationMeta({ page, limit, total }) {
|
|
104
|
+
const totalPages = Math.ceil(total / limit);
|
|
105
|
+
return {
|
|
106
|
+
total,
|
|
107
|
+
page,
|
|
108
|
+
limit,
|
|
109
|
+
totalPages,
|
|
110
|
+
hasNext: page < totalPages,
|
|
111
|
+
hasPrev: page > 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convenience helper that parses pagination params and returns everything
|
|
117
|
+
* needed to call Prisma + build the response envelope in one shot.
|
|
118
|
+
*
|
|
119
|
+
* @param {URLSearchParams | Record<string, string | undefined>} params
|
|
120
|
+
* @param {{ maxLimit?: number }} [options]
|
|
121
|
+
* @returns {{
|
|
122
|
+
* page: number,
|
|
123
|
+
* limit: number,
|
|
124
|
+
* skip: number,
|
|
125
|
+
* take: number,
|
|
126
|
+
* meta: (total: number) => ReturnType<typeof paginationMeta>
|
|
127
|
+
* }}
|
|
128
|
+
*/
|
|
129
|
+
export function parsePaginationQuery(params, options = {}) {
|
|
130
|
+
const { page, limit } = parsePagination(params, options);
|
|
131
|
+
const { skip, take } = paginationToSkip({ page, limit });
|
|
132
|
+
return {
|
|
133
|
+
page,
|
|
134
|
+
limit,
|
|
135
|
+
skip,
|
|
136
|
+
take,
|
|
137
|
+
meta: (total) => paginationMeta({ page, limit, total }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { ValidationError } from "./errors.js";
|
|
4
|
+
import {
|
|
5
|
+
paginationMeta,
|
|
6
|
+
paginationToSkip,
|
|
7
|
+
parsePagination,
|
|
8
|
+
parsePaginationQuery,
|
|
9
|
+
} from "./pagination.js";
|
|
10
|
+
|
|
11
|
+
// ─── parsePagination ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("parsePagination", () => {
|
|
14
|
+
describe("from URLSearchParams", () => {
|
|
15
|
+
it("returns defaults when no params are given", () => {
|
|
16
|
+
const result = parsePagination(new URLSearchParams());
|
|
17
|
+
assert.deepEqual(result, { page: 1, limit: 20 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("parses page and limit", () => {
|
|
21
|
+
const result = parsePagination(new URLSearchParams("page=3&limit=50"));
|
|
22
|
+
assert.deepEqual(result, { page: 3, limit: 50 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("coerces string numbers", () => {
|
|
26
|
+
const result = parsePagination(new URLSearchParams("page=2&limit=10"));
|
|
27
|
+
assert.equal(typeof result.page, "number");
|
|
28
|
+
assert.equal(typeof result.limit, "number");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("from plain object", () => {
|
|
33
|
+
it("accepts a plain object", () => {
|
|
34
|
+
const result = parsePagination({ page: "2", limit: "10" });
|
|
35
|
+
assert.deepEqual(result, { page: 2, limit: 10 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses defaults for undefined values", () => {
|
|
39
|
+
const result = parsePagination({});
|
|
40
|
+
assert.deepEqual(result, { page: 1, limit: 20 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores null values and uses defaults", () => {
|
|
44
|
+
const result = parsePagination({ page: null, limit: null });
|
|
45
|
+
assert.deepEqual(result, { page: 1, limit: 20 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("validation", () => {
|
|
50
|
+
it("throws ValidationError for page < 1", () => {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => parsePagination({ page: "0" }),
|
|
53
|
+
(err) => err instanceof ValidationError,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("throws ValidationError for non-integer page", () => {
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => parsePagination({ page: "1.5" }),
|
|
60
|
+
(err) => err instanceof ValidationError,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws ValidationError for limit > maxLimit (default 100)", () => {
|
|
65
|
+
assert.throws(
|
|
66
|
+
() => parsePagination({ limit: "101" }),
|
|
67
|
+
(err) => err instanceof ValidationError,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("respects custom maxLimit option", () => {
|
|
72
|
+
const result = parsePagination({ limit: "200" }, { maxLimit: 500 });
|
|
73
|
+
assert.equal(result.limit, 200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws when limit exceeds custom maxLimit", () => {
|
|
77
|
+
assert.throws(
|
|
78
|
+
() => parsePagination({ limit: "51" }, { maxLimit: 50 }),
|
|
79
|
+
(err) => err instanceof ValidationError,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ─── paginationToSkip ─────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("paginationToSkip", () => {
|
|
88
|
+
it("page 1 → skip 0", () => {
|
|
89
|
+
assert.deepEqual(paginationToSkip({ page: 1, limit: 20 }), {
|
|
90
|
+
skip: 0,
|
|
91
|
+
take: 20,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("page 2 limit 20 → skip 20", () => {
|
|
96
|
+
assert.deepEqual(paginationToSkip({ page: 2, limit: 20 }), {
|
|
97
|
+
skip: 20,
|
|
98
|
+
take: 20,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("page 3 limit 10 → skip 20", () => {
|
|
103
|
+
assert.deepEqual(paginationToSkip({ page: 3, limit: 10 }), {
|
|
104
|
+
skip: 20,
|
|
105
|
+
take: 10,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("page 5 limit 100 → skip 400", () => {
|
|
110
|
+
assert.deepEqual(paginationToSkip({ page: 5, limit: 100 }), {
|
|
111
|
+
skip: 400,
|
|
112
|
+
take: 100,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ─── paginationMeta ───────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("paginationMeta", () => {
|
|
120
|
+
it("calculates totalPages correctly", () => {
|
|
121
|
+
const meta = paginationMeta({ page: 1, limit: 20, total: 100 });
|
|
122
|
+
assert.equal(meta.totalPages, 5);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("rounds totalPages up for non-divisible totals", () => {
|
|
126
|
+
const meta = paginationMeta({ page: 1, limit: 20, total: 101 });
|
|
127
|
+
assert.equal(meta.totalPages, 6);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("hasNext is true when not on last page", () => {
|
|
131
|
+
const meta = paginationMeta({ page: 2, limit: 10, total: 50 });
|
|
132
|
+
assert.equal(meta.hasNext, true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("hasNext is false on last page", () => {
|
|
136
|
+
const meta = paginationMeta({ page: 5, limit: 10, total: 50 });
|
|
137
|
+
assert.equal(meta.hasNext, false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("hasPrev is true when not on first page", () => {
|
|
141
|
+
const meta = paginationMeta({ page: 3, limit: 10, total: 50 });
|
|
142
|
+
assert.equal(meta.hasPrev, true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("hasPrev is false on first page", () => {
|
|
146
|
+
const meta = paginationMeta({ page: 1, limit: 10, total: 50 });
|
|
147
|
+
assert.equal(meta.hasPrev, false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns total, page, limit in metadata", () => {
|
|
151
|
+
const meta = paginationMeta({ page: 2, limit: 10, total: 45 });
|
|
152
|
+
assert.equal(meta.total, 45);
|
|
153
|
+
assert.equal(meta.page, 2);
|
|
154
|
+
assert.equal(meta.limit, 10);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles total of 0", () => {
|
|
158
|
+
const meta = paginationMeta({ page: 1, limit: 20, total: 0 });
|
|
159
|
+
assert.equal(meta.totalPages, 0);
|
|
160
|
+
assert.equal(meta.hasNext, false);
|
|
161
|
+
assert.equal(meta.hasPrev, false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles single page result", () => {
|
|
165
|
+
const meta = paginationMeta({ page: 1, limit: 20, total: 5 });
|
|
166
|
+
assert.equal(meta.totalPages, 1);
|
|
167
|
+
assert.equal(meta.hasNext, false);
|
|
168
|
+
assert.equal(meta.hasPrev, false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ─── parsePaginationQuery ─────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
describe("parsePaginationQuery", () => {
|
|
175
|
+
it("returns page, limit, skip, take, and meta function", () => {
|
|
176
|
+
const sp = new URLSearchParams("page=2&limit=15");
|
|
177
|
+
const result = parsePaginationQuery(sp);
|
|
178
|
+
assert.equal(result.page, 2);
|
|
179
|
+
assert.equal(result.limit, 15);
|
|
180
|
+
assert.equal(result.skip, 15);
|
|
181
|
+
assert.equal(result.take, 15);
|
|
182
|
+
assert.equal(typeof result.meta, "function");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("meta(total) returns correct pagination envelope", () => {
|
|
186
|
+
const result = parsePaginationQuery(new URLSearchParams("page=2&limit=10"));
|
|
187
|
+
const meta = result.meta(45);
|
|
188
|
+
assert.deepEqual(meta, {
|
|
189
|
+
total: 45,
|
|
190
|
+
page: 2,
|
|
191
|
+
limit: 10,
|
|
192
|
+
totalPages: 5,
|
|
193
|
+
hasNext: true,
|
|
194
|
+
hasPrev: true,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("uses defaults when no params are provided", () => {
|
|
199
|
+
const result = parsePaginationQuery(new URLSearchParams());
|
|
200
|
+
assert.equal(result.skip, 0);
|
|
201
|
+
assert.equal(result.take, 20);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("propagates ValidationError on invalid input", () => {
|
|
205
|
+
assert.throws(
|
|
206
|
+
() => parsePaginationQuery(new URLSearchParams("page=0")),
|
|
207
|
+
(err) => err instanceof ValidationError,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Query Builder
|
|
3
|
+
* Builds Prisma where/orderBy clauses from query parameters
|
|
4
|
+
* with validation and sanitization.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} QueryBuilderOptions
|
|
9
|
+
* @property {string[]} [searchFields] - Fields to search across
|
|
10
|
+
* @property {string[]} [sortableFields] - Fields that can be sorted
|
|
11
|
+
* @property {string[]} [filterableFields] - Fields that can be filtered
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Supported filter operators
|
|
16
|
+
*/
|
|
17
|
+
const OPERATORS = {
|
|
18
|
+
eq: "equals",
|
|
19
|
+
ne: "not",
|
|
20
|
+
gt: "gt",
|
|
21
|
+
gte: "gte",
|
|
22
|
+
lt: "lt",
|
|
23
|
+
lte: "lte",
|
|
24
|
+
contains: "contains",
|
|
25
|
+
startsWith: "startsWith",
|
|
26
|
+
endsWith: "endsWith",
|
|
27
|
+
in: "in",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Query builder for constructing Prisma where/orderBy clauses.
|
|
32
|
+
*/
|
|
33
|
+
export class QueryBuilder {
|
|
34
|
+
/**
|
|
35
|
+
* @param {QueryBuilderOptions} options
|
|
36
|
+
*/
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.searchFields = options.searchFields || [];
|
|
39
|
+
this.sortableFields = options.sortableFields || [];
|
|
40
|
+
this.filterableFields = options.filterableFields || [];
|
|
41
|
+
this.filters = [];
|
|
42
|
+
this.searchTerm = null;
|
|
43
|
+
this.sortField = null;
|
|
44
|
+
this.sortDirection = "asc";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a filter condition.
|
|
49
|
+
* @param {string} field - Field name
|
|
50
|
+
* @param {string} operator - Operator (eq, ne, gt, gte, lt, lte, contains, startsWith, endsWith, in)
|
|
51
|
+
* @param {any} value - Filter value
|
|
52
|
+
* @returns {QueryBuilder}
|
|
53
|
+
*/
|
|
54
|
+
filter(field, operator, value) {
|
|
55
|
+
if (!this.filterableFields.includes(field)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Field "${field}" is not filterable. Allowed: ${this.filterableFields.join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!OPERATORS[operator]) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Operator "${operator}" is not supported. Allowed: ${Object.keys(OPERATORS).join(", ")}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.filters.push({ field, operator, value });
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add a full-text search across multiple fields.
|
|
73
|
+
* @param {string} term - Search term
|
|
74
|
+
* @returns {QueryBuilder}
|
|
75
|
+
*/
|
|
76
|
+
search(term) {
|
|
77
|
+
if (!term || typeof term !== "string") {
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.searchFields.length === 0) {
|
|
82
|
+
throw new Error("No search fields configured for this query");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.searchTerm = term.trim();
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set sort field and direction.
|
|
91
|
+
* @param {string} field - Field name
|
|
92
|
+
* @param {"asc" | "desc"} [direction="asc"] - Sort direction
|
|
93
|
+
* @returns {QueryBuilder}
|
|
94
|
+
*/
|
|
95
|
+
sort(field, direction = "asc") {
|
|
96
|
+
if (!this.sortableFields.includes(field)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Field "${field}" is not sortable. Allowed: ${this.sortableFields.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (direction !== "asc" && direction !== "desc") {
|
|
103
|
+
throw new Error('Sort direction must be "asc" or "desc"');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.sortField = field;
|
|
107
|
+
this.sortDirection = direction;
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build Prisma where clause.
|
|
113
|
+
* @returns {Object} Prisma where object
|
|
114
|
+
*/
|
|
115
|
+
toWhere() {
|
|
116
|
+
const conditions = [];
|
|
117
|
+
|
|
118
|
+
// Add filter conditions
|
|
119
|
+
for (const { field, operator, value } of this.filters) {
|
|
120
|
+
const prismaOp = OPERATORS[operator];
|
|
121
|
+
|
|
122
|
+
if (operator === "in") {
|
|
123
|
+
// Special handling for "in" operator - value should be an array
|
|
124
|
+
const values = Array.isArray(value) ? value : [value];
|
|
125
|
+
conditions.push({ [field]: { in: values } });
|
|
126
|
+
} else if (operator === "ne") {
|
|
127
|
+
// "not" operator wraps the value
|
|
128
|
+
conditions.push({ [field]: { not: value } });
|
|
129
|
+
} else {
|
|
130
|
+
conditions.push({ [field]: { [prismaOp]: value } });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Add search condition
|
|
135
|
+
if (this.searchTerm) {
|
|
136
|
+
const searchConditions = this.searchFields.map((field) => ({
|
|
137
|
+
[field]: { contains: this.searchTerm, mode: "insensitive" },
|
|
138
|
+
}));
|
|
139
|
+
conditions.push({ OR: searchConditions });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Return combined conditions
|
|
143
|
+
if (conditions.length === 0) {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
if (conditions.length === 1) {
|
|
147
|
+
return conditions[0];
|
|
148
|
+
}
|
|
149
|
+
return { AND: conditions };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build Prisma orderBy clause.
|
|
154
|
+
* @returns {Object | undefined} Prisma orderBy object
|
|
155
|
+
*/
|
|
156
|
+
toOrderBy() {
|
|
157
|
+
if (!this.sortField) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
return { [this.sortField]: this.sortDirection };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reset all filters, search, and sort.
|
|
165
|
+
* @returns {QueryBuilder}
|
|
166
|
+
*/
|
|
167
|
+
reset() {
|
|
168
|
+
this.filters = [];
|
|
169
|
+
this.searchTerm = null;
|
|
170
|
+
this.sortField = null;
|
|
171
|
+
this.sortDirection = "asc";
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Factory function to create a new QueryBuilder.
|
|
178
|
+
* @param {QueryBuilderOptions} options
|
|
179
|
+
* @returns {QueryBuilder}
|
|
180
|
+
*/
|
|
181
|
+
export function createQueryBuilder(options) {
|
|
182
|
+
return new QueryBuilder(options);
|
|
183
|
+
}
|