@edium/halifax 1.0.0 → 2.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/CHANGELOG.md +97 -0
- package/README.md +72 -50
- package/README_AUTOCRUD.md +94 -19
- package/README_QUERYBUILDER.md +1 -1
- package/README_REPO_ADAPTERS.md +80 -11
- package/dist/adapters/http/ExpressAdapter.d.ts +34 -5
- package/dist/adapters/http/ExpressAdapter.js +20 -12
- package/dist/adapters/http/FastifyAdapter.d.ts +93 -0
- package/dist/adapters/http/FastifyAdapter.js +125 -0
- package/dist/adapters/http/HyperExpressAdapter.d.ts +82 -0
- package/dist/adapters/http/HyperExpressAdapter.js +128 -0
- package/dist/adapters/http/UltimateExpressAdapter.d.ts +84 -0
- package/dist/adapters/http/UltimateExpressAdapter.js +108 -0
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +89 -40
- package/dist/adapters/orm/prisma/PrismaAdapter.js +233 -71
- package/dist/adapters/orm/prisma/astToPrisma.d.ts +26 -0
- package/dist/adapters/orm/prisma/astToPrisma.js +140 -0
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts +1 -2
- package/dist/adapters/orm/prisma/createPrismaResources.js +10 -6
- package/dist/adapters/orm/prisma/helpers.d.ts +0 -1
- package/dist/adapters/orm/prisma/helpers.js +0 -1
- package/dist/adapters/orm/prisma/index.d.ts +1 -2
- package/dist/adapters/orm/prisma/index.js +0 -1
- package/dist/adapters/orm/prisma/types.d.ts +14 -9
- package/dist/adapters/orm/prisma/types.js +0 -1
- package/dist/auth/AuthStrategy.d.ts +0 -9
- package/dist/auth/AuthStrategy.js +0 -7
- package/dist/core/cache/CacheStore.d.ts +25 -0
- package/dist/core/cache/CacheStore.js +1 -0
- package/dist/core/cache/createCachingRepository.d.ts +39 -0
- package/dist/core/cache/createCachingRepository.js +116 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +19 -0
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +34 -0
- package/dist/core/cache/index.d.ts +5 -0
- package/dist/core/cache/index.js +5 -0
- package/dist/core/cache/redis/RedisCacheStore.d.ts +28 -0
- package/dist/core/cache/redis/RedisCacheStore.js +42 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +12 -0
- package/dist/core/cache/redis/RedisLikeClient.js +1 -0
- package/dist/core/crudRouter.d.ts +72 -8
- package/dist/core/crudRouter.js +266 -105
- package/dist/core/queryString.d.ts +3 -3
- package/dist/core/queryString.js +16 -7
- package/dist/core/types.d.ts +151 -31
- package/dist/core/types.js +13 -1
- package/dist/core/validation.d.ts +12 -4
- package/dist/core/validation.js +33 -13
- package/dist/enums/SqlComparison.d.ts +13 -3
- package/dist/enums/SqlComparison.js +12 -2
- package/dist/enums/SqlOperator.d.ts +0 -1
- package/dist/enums/SqlOperator.js +0 -1
- package/dist/enums/SqlOrder.d.ts +0 -1
- package/dist/enums/SqlOrder.js +0 -1
- package/dist/errors/AuthenticationError.d.ts +0 -1
- package/dist/errors/AuthenticationError.js +0 -1
- package/dist/errors/AuthorizationError.d.ts +0 -1
- package/dist/errors/AuthorizationError.js +0 -1
- package/dist/errors/BadRequestError.d.ts +0 -1
- package/dist/errors/BadRequestError.js +0 -1
- package/dist/errors/HttpError.d.ts +0 -1
- package/dist/errors/HttpError.js +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts +0 -1
- package/dist/errors/MethodNotAllowedError.js +0 -1
- package/dist/errors/NotAcceptableError.d.ts +0 -1
- package/dist/errors/NotAcceptableError.js +0 -1
- package/dist/errors/NotFoundError.d.ts +0 -1
- package/dist/errors/NotFoundError.js +0 -1
- package/dist/errors/NotImplementedError.d.ts +0 -1
- package/dist/errors/NotImplementedError.js +0 -1
- package/dist/errors/ServerError.d.ts +0 -1
- package/dist/errors/ServerError.js +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts +0 -1
- package/dist/errors/UnprocessableEntityError.js +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js +0 -1
- package/dist/index.d.ts +1 -3
- package/dist/index.js +1 -3
- package/dist/interfaces/IQueryFilter.d.ts +1 -2
- package/dist/interfaces/IQueryFilter.js +0 -1
- package/dist/interfaces/IQueryOptions.d.ts +9 -9
- package/dist/interfaces/IQueryOptions.js +0 -1
- package/dist/interfaces/ISort.d.ts +0 -1
- package/dist/interfaces/ISort.js +0 -1
- package/package.json +10 -8
- package/dist/adapters/http/ExpressAdapter.d.ts.map +0 -1
- package/dist/adapters/http/ExpressAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.js.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/createPrismaResources.js.map +0 -1
- package/dist/adapters/orm/prisma/helpers.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/helpers.js.map +0 -1
- package/dist/adapters/orm/prisma/index.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/index.js.map +0 -1
- package/dist/adapters/orm/prisma/types.d.ts.map +0 -1
- package/dist/adapters/orm/prisma/types.js.map +0 -1
- package/dist/auth/AuthStrategy.d.ts.map +0 -1
- package/dist/auth/AuthStrategy.js.map +0 -1
- package/dist/classes/QueryBuilder.d.ts +0 -33
- package/dist/classes/QueryBuilder.d.ts.map +0 -1
- package/dist/classes/QueryBuilder.js +0 -262
- package/dist/classes/QueryBuilder.js.map +0 -1
- package/dist/core/crudRouter.d.ts.map +0 -1
- package/dist/core/crudRouter.js.map +0 -1
- package/dist/core/queryString.d.ts.map +0 -1
- package/dist/core/queryString.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/validation.d.ts.map +0 -1
- package/dist/core/validation.js.map +0 -1
- package/dist/enums/SqlComparison.d.ts.map +0 -1
- package/dist/enums/SqlComparison.js.map +0 -1
- package/dist/enums/SqlOperator.d.ts.map +0 -1
- package/dist/enums/SqlOperator.js.map +0 -1
- package/dist/enums/SqlOrder.d.ts.map +0 -1
- package/dist/enums/SqlOrder.js.map +0 -1
- package/dist/errors/AuthenticationError.d.ts.map +0 -1
- package/dist/errors/AuthenticationError.js.map +0 -1
- package/dist/errors/AuthorizationError.d.ts.map +0 -1
- package/dist/errors/AuthorizationError.js.map +0 -1
- package/dist/errors/BadRequestError.d.ts.map +0 -1
- package/dist/errors/BadRequestError.js.map +0 -1
- package/dist/errors/HttpError.d.ts.map +0 -1
- package/dist/errors/HttpError.js.map +0 -1
- package/dist/errors/MethodNotAllowedError.d.ts.map +0 -1
- package/dist/errors/MethodNotAllowedError.js.map +0 -1
- package/dist/errors/NotAcceptableError.d.ts.map +0 -1
- package/dist/errors/NotAcceptableError.js.map +0 -1
- package/dist/errors/NotFoundError.d.ts.map +0 -1
- package/dist/errors/NotFoundError.js.map +0 -1
- package/dist/errors/NotImplementedError.d.ts.map +0 -1
- package/dist/errors/NotImplementedError.js.map +0 -1
- package/dist/errors/ServerError.d.ts.map +0 -1
- package/dist/errors/ServerError.js.map +0 -1
- package/dist/errors/UnprocessableEntityError.d.ts.map +0 -1
- package/dist/errors/UnprocessableEntityError.js.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.d.ts.map +0 -1
- package/dist/errors/UnsupportedMediaTypeError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/interfaces/IParamQuery.d.ts +0 -8
- package/dist/interfaces/IParamQuery.d.ts.map +0 -1
- package/dist/interfaces/IParamQuery.js +0 -2
- package/dist/interfaces/IParamQuery.js.map +0 -1
- package/dist/interfaces/IQueryFilter.d.ts.map +0 -1
- package/dist/interfaces/IQueryFilter.js.map +0 -1
- package/dist/interfaces/IQueryOptions.d.ts.map +0 -1
- package/dist/interfaces/IQueryOptions.js.map +0 -1
- package/dist/interfaces/ISort.d.ts.map +0 -1
- package/dist/interfaces/ISort.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { QueryBuilder } from '../../../classes/QueryBuilder.js';
|
|
2
1
|
import { NotImplementedError } from '../../../errors/NotImplementedError.js';
|
|
2
|
+
import { NotFoundError } from '../../../errors/NotFoundError.js';
|
|
3
3
|
import { ServerError } from '../../../errors/ServerError.js';
|
|
4
|
+
import { astToPrismaWhere, astToPrismaOrderBy } from './astToPrisma.js';
|
|
4
5
|
/** Returns true for Prisma's P2025 "record not found" error. */
|
|
5
6
|
function isNotFoundError(error) {
|
|
6
7
|
return (typeof error === 'object' &&
|
|
@@ -10,50 +11,129 @@ function isNotFoundError(error) {
|
|
|
10
11
|
}
|
|
11
12
|
import { toSelect, toInclude, toOrderBy } from './helpers.js';
|
|
12
13
|
/**
|
|
13
|
-
* PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform
|
|
14
|
-
* It
|
|
15
|
-
*
|
|
16
|
-
* It also extracts field and relation definitions from a provided
|
|
14
|
+
* PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform
|
|
15
|
+
* database operations. It handles CRUD plus the query-builder/bulk paths by compiling the
|
|
16
|
+
* query AST to portable Prisma Client calls (no raw SQL), so it works on every Prisma
|
|
17
|
+
* provider. It also extracts field and relation definitions from a provided model schema.
|
|
17
18
|
*/
|
|
18
19
|
export class PrismaAdapter {
|
|
19
|
-
/** Private properties to hold the Prisma delegate
|
|
20
|
+
/** Private properties to hold the Prisma delegate and configuration options. */
|
|
20
21
|
delegate;
|
|
21
|
-
/** Optional Prisma client for executing raw SQL queries, required for certain operations like updateMany and deleteMany. */
|
|
22
|
-
client;
|
|
23
22
|
/** The field name used for the primary key in the model. */
|
|
24
23
|
idField;
|
|
25
|
-
/** The name of the database table associated with the model. */
|
|
26
|
-
tableName;
|
|
27
24
|
/** A flag indicating whether to return created records. */
|
|
28
25
|
returnCreated;
|
|
26
|
+
/** The original construction options, used to build request-scoped clones. */
|
|
27
|
+
options;
|
|
28
|
+
/** Tenant constraint bound to this instance, or `undefined` for unscoped access. */
|
|
29
|
+
scope;
|
|
29
30
|
/** A set of capabilities that the repository supports. */
|
|
30
31
|
capabilities;
|
|
31
|
-
/** An array of field definitions for the model. */
|
|
32
|
+
/** An array of field definitions for the model (present when built with a `model`). */
|
|
32
33
|
fields;
|
|
33
|
-
/** An array of relation definitions for the model. */
|
|
34
|
+
/** An array of relation definitions for the model (present when built with a `model`). */
|
|
34
35
|
relations;
|
|
35
36
|
/**
|
|
36
37
|
* Constructs a new instance of PrismaAdapter with the provided options.
|
|
37
|
-
* @param options -
|
|
38
|
+
* @param options - The Prisma delegate plus optional id field, return-created flag, and model schema.
|
|
38
39
|
*/
|
|
39
40
|
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
40
42
|
this.delegate = options.delegate;
|
|
41
|
-
this.client = options.client;
|
|
42
43
|
this.idField = options.idField ?? 'id';
|
|
43
|
-
this.tableName = options.tableName;
|
|
44
44
|
this.returnCreated = options.returnCreated ?? false;
|
|
45
|
+
this.scope = options.scope;
|
|
45
46
|
this.capabilities = {
|
|
46
|
-
supportsNativeSql: !!options.client,
|
|
47
47
|
supportsIncludes: true,
|
|
48
|
-
|
|
49
|
-
supportsCreateManyReturn: this.returnCreated,
|
|
50
|
-
supportsNoSqlQueryAst: false
|
|
48
|
+
supportsCreateManyReturn: this.returnCreated
|
|
51
49
|
};
|
|
52
50
|
if (options.model) {
|
|
53
51
|
this.fields = PrismaAdapter.fieldsFromModel(options.model);
|
|
54
52
|
this.relations = PrismaAdapter.relationsFromModel(options.model);
|
|
55
53
|
}
|
|
56
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Returns a request-scoped clone of this adapter bound to `scope`. Every read is
|
|
57
|
+
* filtered by the scope, every write is stamped with it, and every bulk/SQL operation
|
|
58
|
+
* has the scope AND-ed into its WHERE clause. The original instance is never mutated.
|
|
59
|
+
* @param scope - The resolved tenant constraint for the current request.
|
|
60
|
+
* @returns A new {@link PrismaAdapter} that enforces `scope` on all operations.
|
|
61
|
+
*/
|
|
62
|
+
withScope(scope) {
|
|
63
|
+
return new PrismaAdapter({ ...this.options, scope });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Merges the bound tenant constraint into a Prisma `where` object. The scope is spread
|
|
67
|
+
* last so it always wins over any caller-supplied value for the same key.
|
|
68
|
+
* @param where - The caller-derived where clause (may be undefined).
|
|
69
|
+
* @returns A where object with the tenant constraint applied, or `where` when unscoped.
|
|
70
|
+
*/
|
|
71
|
+
scopedWhere(where) {
|
|
72
|
+
if (!this.scope)
|
|
73
|
+
return where;
|
|
74
|
+
return { ...(where ?? {}), [this.scope.field]: this.scope.value };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Removes the tenant field from a write payload so callers can never reassign a row
|
|
78
|
+
* to another tenant via create/update/upsert bodies. No-op when unscoped.
|
|
79
|
+
* @param data - The write payload to sanitise.
|
|
80
|
+
* @returns A copy of `data` without the tenant field, or `data` when unscoped.
|
|
81
|
+
*/
|
|
82
|
+
stripTenant(data) {
|
|
83
|
+
if (!this.scope)
|
|
84
|
+
return data;
|
|
85
|
+
if (data === null || typeof data !== 'object')
|
|
86
|
+
return data;
|
|
87
|
+
const copy = { ...data };
|
|
88
|
+
delete copy[this.scope.field];
|
|
89
|
+
return copy;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Stamps the bound tenant value onto a create payload, overriding any caller-supplied
|
|
93
|
+
* value for the tenant field. No-op when unscoped.
|
|
94
|
+
* @param data - The create payload.
|
|
95
|
+
* @returns A copy of `data` with the tenant field forced to the scope value.
|
|
96
|
+
*/
|
|
97
|
+
stampTenant(data) {
|
|
98
|
+
if (!this.scope)
|
|
99
|
+
return data;
|
|
100
|
+
return { ...data, [this.scope.field]: this.scope.value };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* AND-s the bound tenant constraint into a query-builder WHERE clause. The caller's filters
|
|
104
|
+
* are nested as a child group beneath the tenant condition, so a caller-supplied `OR` can
|
|
105
|
+
* never break out of the tenant boundary once the AST is compiled to a Prisma `where`.
|
|
106
|
+
* @param where - The caller-supplied filter list (may be undefined/empty).
|
|
107
|
+
* @returns A new filter list with the tenant condition enforced, or `where` when unscoped.
|
|
108
|
+
*/
|
|
109
|
+
scopedFilters(where) {
|
|
110
|
+
if (!this.scope)
|
|
111
|
+
return where;
|
|
112
|
+
const tenantNode = {
|
|
113
|
+
field: this.scope.field,
|
|
114
|
+
comparison: '=',
|
|
115
|
+
value1: this.scope.value
|
|
116
|
+
};
|
|
117
|
+
if (where?.length) {
|
|
118
|
+
tenantNode.operator = 'AND';
|
|
119
|
+
tenantNode.children = where;
|
|
120
|
+
}
|
|
121
|
+
return [tenantNode];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolves a query-builder AST for the tenant-scoped paths: applies the tenant constraint
|
|
125
|
+
* via {@link PrismaAdapter.scopedFilters}. The `where` key is only set when defined (to
|
|
126
|
+
* satisfy `exactOptionalPropertyTypes`).
|
|
127
|
+
* @param query - The incoming query AST.
|
|
128
|
+
* @returns A new AST with the tenant scope applied.
|
|
129
|
+
*/
|
|
130
|
+
resolveScopedQuery(query) {
|
|
131
|
+
const resolved = { ...query };
|
|
132
|
+
const where = this.scopedFilters(query.where);
|
|
133
|
+
if (where)
|
|
134
|
+
resolved.where = where;
|
|
135
|
+
return resolved;
|
|
136
|
+
}
|
|
57
137
|
/**
|
|
58
138
|
* Extracts field definitions from a Prisma model schema.
|
|
59
139
|
* @param model - The Prisma model schema.
|
|
@@ -89,11 +169,20 @@ export class PrismaAdapter {
|
|
|
89
169
|
async getOne(id, options) {
|
|
90
170
|
const select = toSelect(options?.fields);
|
|
91
171
|
const include = toInclude(options?.include);
|
|
92
|
-
const args = { where: { [this.idField]: id } };
|
|
172
|
+
const args = { where: this.scopedWhere({ [this.idField]: id }) };
|
|
93
173
|
if (select)
|
|
94
174
|
args.select = select;
|
|
95
175
|
else if (include)
|
|
96
176
|
args.include = include;
|
|
177
|
+
// When scoped, the WHERE carries a non-unique tenant predicate, so we must use
|
|
178
|
+
// findFirst (findUnique only accepts unique fields). This is what enforces that a
|
|
179
|
+
// row outside the caller's tenant reads back as "not found".
|
|
180
|
+
if (this.scope) {
|
|
181
|
+
if (this.delegate.findFirst) {
|
|
182
|
+
return (await this.delegate.findFirst(args));
|
|
183
|
+
}
|
|
184
|
+
throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
|
|
185
|
+
}
|
|
97
186
|
if (this.delegate.findUnique) {
|
|
98
187
|
return (await this.delegate.findUnique(args));
|
|
99
188
|
}
|
|
@@ -111,8 +200,9 @@ export class PrismaAdapter {
|
|
|
111
200
|
async getMany(options = {}) {
|
|
112
201
|
const select = toSelect(options.fields);
|
|
113
202
|
const include = toInclude(options.include);
|
|
203
|
+
const where = this.scopedWhere(options.where);
|
|
114
204
|
const args = {
|
|
115
|
-
where
|
|
205
|
+
where,
|
|
116
206
|
orderBy: toOrderBy(options.orderBy),
|
|
117
207
|
skip: options.offset,
|
|
118
208
|
take: options.limit
|
|
@@ -122,7 +212,7 @@ export class PrismaAdapter {
|
|
|
122
212
|
else if (include)
|
|
123
213
|
args.include = include;
|
|
124
214
|
const [count, results] = await Promise.all([
|
|
125
|
-
this.delegate.count({ where
|
|
215
|
+
this.delegate.count({ where }),
|
|
126
216
|
this.delegate.findMany(args)
|
|
127
217
|
]);
|
|
128
218
|
return { count, results: results };
|
|
@@ -134,7 +224,7 @@ export class PrismaAdapter {
|
|
|
134
224
|
* @throws ServerError if the Prisma delegate does not support the create method.
|
|
135
225
|
*/
|
|
136
226
|
async createOne(data) {
|
|
137
|
-
return (await this.delegate.create({ data }));
|
|
227
|
+
return (await this.delegate.create({ data: this.stampTenant(data) }));
|
|
138
228
|
}
|
|
139
229
|
/**
|
|
140
230
|
* Creates multiple records in the database using the provided array of data objects.
|
|
@@ -147,7 +237,7 @@ export class PrismaAdapter {
|
|
|
147
237
|
if (!this.delegate.createMany || this.returnCreated) {
|
|
148
238
|
return await Promise.all(data.map((item) => this.createOne(item)));
|
|
149
239
|
}
|
|
150
|
-
await this.delegate.createMany({ data });
|
|
240
|
+
await this.delegate.createMany({ data: data.map((item) => this.stampTenant(item)) });
|
|
151
241
|
return [];
|
|
152
242
|
}
|
|
153
243
|
/**
|
|
@@ -158,6 +248,20 @@ export class PrismaAdapter {
|
|
|
158
248
|
* @throws ServerError if the Prisma delegate does not support the update method.
|
|
159
249
|
*/
|
|
160
250
|
async updateOne(id, data) {
|
|
251
|
+
// When scoped, confirm the row belongs to the caller's tenant before touching it,
|
|
252
|
+
// and strip the tenant field from the payload so the row can't be moved tenants.
|
|
253
|
+
if (this.scope) {
|
|
254
|
+
if (!this.delegate.findFirst) {
|
|
255
|
+
throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
|
|
256
|
+
}
|
|
257
|
+
const owned = await this.delegate.findFirst({
|
|
258
|
+
where: this.scopedWhere({ [this.idField]: id }),
|
|
259
|
+
select: { [this.idField]: true }
|
|
260
|
+
});
|
|
261
|
+
if (!owned)
|
|
262
|
+
return null;
|
|
263
|
+
data = this.stripTenant(data);
|
|
264
|
+
}
|
|
161
265
|
try {
|
|
162
266
|
return (await this.delegate.update({ where: { [this.idField]: id }, data }));
|
|
163
267
|
}
|
|
@@ -168,29 +272,31 @@ export class PrismaAdapter {
|
|
|
168
272
|
}
|
|
169
273
|
}
|
|
170
274
|
/**
|
|
171
|
-
* Updates
|
|
172
|
-
* This method requires a Prisma client for executing raw SQL queries, as Prisma does not
|
|
173
|
-
* natively support bulk updates with return values. It first selects the IDs of the records
|
|
174
|
-
* to be updated based on the query options, then executes an update query, and finally returns
|
|
175
|
-
* the IDs of the updated records.
|
|
275
|
+
* Updates every record matching the query and returns the IDs of the affected rows.
|
|
176
276
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
277
|
+
* The query AST is compiled to a portable Prisma `where` (no raw SQL), so this works on
|
|
278
|
+
* every Prisma provider. Because Prisma's `updateMany` only returns a count, the matching
|
|
279
|
+
* IDs are selected first and then the bulk update is applied.
|
|
180
280
|
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* @
|
|
185
|
-
* @
|
|
281
|
+
* **Note:** the SELECT and UPDATE are two separate statements without a transaction; under
|
|
282
|
+
* concurrent writes the returned `updated` IDs may differ from the rows actually modified.
|
|
283
|
+
*
|
|
284
|
+
* @param query - Query AST describing which rows to update (filtered, tenant-scoped).
|
|
285
|
+
* @param data - Fields to apply to all matching rows (the tenant field is stripped).
|
|
286
|
+
* @returns The IDs of the updated rows.
|
|
287
|
+
* @throws NotImplementedError when the delegate does not support `updateMany`.
|
|
186
288
|
*/
|
|
187
289
|
async updateMany(query, data) {
|
|
188
|
-
if (!this.
|
|
189
|
-
throw new NotImplementedError('
|
|
290
|
+
if (!this.delegate.updateMany) {
|
|
291
|
+
throw new NotImplementedError('This repository does not support updateMany.');
|
|
190
292
|
}
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
293
|
+
const where = astToPrismaWhere(this.resolveScopedQuery(query).where);
|
|
294
|
+
const rows = (await this.delegate.findMany({
|
|
295
|
+
where,
|
|
296
|
+
select: { [this.idField]: true }
|
|
297
|
+
}));
|
|
298
|
+
await this.delegate.updateMany({ where, data: this.stripTenant(data) });
|
|
299
|
+
return { updated: rows.map((item) => item[this.idField]) };
|
|
194
300
|
}
|
|
195
301
|
/**
|
|
196
302
|
* Upserts a single record identified by its ID with the provided data. If the record does not exist, it creates a new one.
|
|
@@ -205,6 +311,25 @@ export class PrismaAdapter {
|
|
|
205
311
|
if (!this.delegate.upsert) {
|
|
206
312
|
throw new NotImplementedError('Prisma delegate does not support upsert.');
|
|
207
313
|
}
|
|
314
|
+
// When scoped, an upsert keyed on a unique id could otherwise overwrite a row owned
|
|
315
|
+
// by another tenant. Reject that case (hidden as "not found"), stamp the tenant on
|
|
316
|
+
// create, and forbid reassigning the tenant on update.
|
|
317
|
+
if (this.scope) {
|
|
318
|
+
if (!this.delegate.findFirst) {
|
|
319
|
+
throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
|
|
320
|
+
}
|
|
321
|
+
const existing = (await this.delegate.findFirst({
|
|
322
|
+
where: { [this.idField]: id }
|
|
323
|
+
}));
|
|
324
|
+
if (existing && existing[this.scope.field] !== this.scope.value) {
|
|
325
|
+
throw new NotFoundError();
|
|
326
|
+
}
|
|
327
|
+
return (await this.delegate.upsert({
|
|
328
|
+
where: { [this.idField]: id },
|
|
329
|
+
create: this.stampTenant(data),
|
|
330
|
+
update: this.stripTenant(data)
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
208
333
|
return (await this.delegate.upsert({
|
|
209
334
|
where: { [this.idField]: id },
|
|
210
335
|
create: data,
|
|
@@ -220,6 +345,26 @@ export class PrismaAdapter {
|
|
|
220
345
|
* @throws ServerError if the Prisma delegate does not support the required methods for deleting records.
|
|
221
346
|
*/
|
|
222
347
|
async deleteOne(id) {
|
|
348
|
+
// When scoped, delete through deleteMany with the tenant predicate so the ownership
|
|
349
|
+
// check and the delete are a single atomic statement (no TOCTOU window). A row in
|
|
350
|
+
// another tenant simply matches nothing and reports as "not found".
|
|
351
|
+
if (this.scope) {
|
|
352
|
+
if (this.delegate.deleteMany) {
|
|
353
|
+
const result = await this.delegate.deleteMany({
|
|
354
|
+
where: this.scopedWhere({ [this.idField]: id })
|
|
355
|
+
});
|
|
356
|
+
return (result?.count ?? 0) > 0;
|
|
357
|
+
}
|
|
358
|
+
if (!this.delegate.findFirst) {
|
|
359
|
+
throw new ServerError('Prisma delegate does not support deleteMany or findFirst (required for tenant scoping).');
|
|
360
|
+
}
|
|
361
|
+
const owned = await this.delegate.findFirst({
|
|
362
|
+
where: this.scopedWhere({ [this.idField]: id }),
|
|
363
|
+
select: { [this.idField]: true }
|
|
364
|
+
});
|
|
365
|
+
if (!owned)
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
223
368
|
try {
|
|
224
369
|
await this.delegate.delete({ where: { [this.idField]: id } });
|
|
225
370
|
return true;
|
|
@@ -238,40 +383,57 @@ export class PrismaAdapter {
|
|
|
238
383
|
* Under concurrent writes, rows inserted or deleted between the two queries may cause the
|
|
239
384
|
* returned `deleted` IDs to differ from the rows actually removed.
|
|
240
385
|
*
|
|
241
|
-
* @param query -
|
|
242
|
-
* @returns
|
|
243
|
-
* @throws NotImplementedError
|
|
244
|
-
* @throws ServerError if the Prisma client does not support the required methods for executing raw SQL queries.
|
|
386
|
+
* @param query - Query AST describing which rows to delete (filtered, tenant-scoped).
|
|
387
|
+
* @returns The IDs of the deleted rows.
|
|
388
|
+
* @throws NotImplementedError when the delegate does not support `deleteMany`.
|
|
245
389
|
*/
|
|
246
390
|
async deleteMany(query) {
|
|
247
|
-
if (!this.
|
|
248
|
-
throw new NotImplementedError('
|
|
391
|
+
if (!this.delegate.deleteMany) {
|
|
392
|
+
throw new NotImplementedError('This repository does not support deleteMany.');
|
|
249
393
|
}
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
394
|
+
const where = astToPrismaWhere(this.resolveScopedQuery(query).where);
|
|
395
|
+
const rows = (await this.delegate.findMany({
|
|
396
|
+
where,
|
|
397
|
+
select: { [this.idField]: true }
|
|
398
|
+
}));
|
|
399
|
+
await this.delegate.deleteMany({ where });
|
|
400
|
+
return { deleted: rows.map((item) => item[this.idField]) };
|
|
254
401
|
}
|
|
255
402
|
/**
|
|
256
|
-
* Executes a query
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
403
|
+
* Executes a query-builder AST as a portable Prisma query: the WHERE tree is compiled to a
|
|
404
|
+
* Prisma `where`, and field projection, ordering, pagination, and `distinct` are mapped to
|
|
405
|
+
* `findMany` arguments. No raw SQL is generated, so the same query runs identically on every
|
|
406
|
+
* Prisma provider (PostgreSQL, MySQL, SQLite, SQL Server, CockroachDB, MongoDB).
|
|
407
|
+
*
|
|
408
|
+
* Validation (field/comparison/depth checks → 4xx) happens in the router *before* this
|
|
409
|
+
* method, so malformed queries never reach Prisma.
|
|
410
|
+
*
|
|
411
|
+
* **Note:** the COUNT and SELECT are two separate statements without a transaction; under
|
|
412
|
+
* concurrent writes the returned `count` may differ from the number of rows in `results`.
|
|
413
|
+
*
|
|
414
|
+
* @param query - The validated query AST (filters, sort, pagination, projection, distinct).
|
|
415
|
+
* @returns A count-and-results envelope for the matching rows.
|
|
261
416
|
*/
|
|
262
|
-
async
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
417
|
+
async executeQuery(query) {
|
|
418
|
+
const resolved = this.resolveScopedQuery(query);
|
|
419
|
+
const where = astToPrismaWhere(resolved.where);
|
|
420
|
+
const args = { where };
|
|
421
|
+
const select = toSelect(resolved.fields);
|
|
422
|
+
if (select)
|
|
423
|
+
args.select = select;
|
|
424
|
+
const orderBy = astToPrismaOrderBy(resolved.orderBy);
|
|
425
|
+
if (orderBy)
|
|
426
|
+
args.orderBy = orderBy;
|
|
427
|
+
if (resolved.limit !== undefined)
|
|
428
|
+
args.take = resolved.limit;
|
|
429
|
+
if (resolved.offset !== undefined)
|
|
430
|
+
args.skip = resolved.offset;
|
|
431
|
+
if (resolved.distinct?.length)
|
|
432
|
+
args.distinct = resolved.distinct;
|
|
433
|
+
const [count, results] = await Promise.all([
|
|
434
|
+
this.delegate.count({ where }),
|
|
435
|
+
this.delegate.findMany(args)
|
|
436
|
+
]);
|
|
437
|
+
return { count, results: results };
|
|
275
438
|
}
|
|
276
439
|
}
|
|
277
|
-
//# sourceMappingURL=PrismaAdapter.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IQueryFilter } from '../../../interfaces/IQueryFilter.js';
|
|
2
|
+
import type { ISort } from '../../../interfaces/ISort.js';
|
|
3
|
+
/** A Prisma `where` fragment — an arbitrary nested object of field conditions and AND/OR/NOT groups. */
|
|
4
|
+
export type PrismaWhere = Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Translates a validated query-builder WHERE tree ({@link IQueryFilter}[]) into a Prisma
|
|
7
|
+
* `where` object.
|
|
8
|
+
*
|
|
9
|
+
* Logical precedence matches SQL: each filter's `operator` joins it to the *next* sibling,
|
|
10
|
+
* `AND` binds tighter than `OR`, so the list is split into OR-separated groups of AND-runs.
|
|
11
|
+
* A single condition is returned bare; an all-AND list as `{ AND: [...] }`; mixed operators
|
|
12
|
+
* as `{ OR: [{ AND: [...] }, ...] }`.
|
|
13
|
+
*
|
|
14
|
+
* This runs **after** {@link validateAdvancedQuery}, so every field name and comparison has
|
|
15
|
+
* already been checked — the 4xx errors are raised there, never inside Prisma.
|
|
16
|
+
*
|
|
17
|
+
* @param where - The validated filter list (may be empty/undefined).
|
|
18
|
+
* @returns A Prisma `where` object (empty `{}` when there are no filters).
|
|
19
|
+
*/
|
|
20
|
+
export declare function astToPrismaWhere(where?: IQueryFilter[]): PrismaWhere;
|
|
21
|
+
/**
|
|
22
|
+
* Converts the AST `orderBy` ({@link ISort}[]) into a Prisma `orderBy` array.
|
|
23
|
+
* @param orderBy - Sort expressions from the query AST.
|
|
24
|
+
* @returns A Prisma `orderBy` array, or `undefined` when there are no sorts.
|
|
25
|
+
*/
|
|
26
|
+
export declare function astToPrismaOrderBy(orderBy?: ISort[]): Array<Record<string, 'asc' | 'desc'>> | undefined;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SqlComparison } from '../../../enums/SqlComparison.js';
|
|
2
|
+
import { SqlOperator } from '../../../enums/SqlOperator.js';
|
|
3
|
+
import { SqlOrder } from '../../../enums/SqlOrder.js';
|
|
4
|
+
/**
|
|
5
|
+
* Splits a `LIKE` pattern into a Prisma string operator based on its `%` wildcards.
|
|
6
|
+
* `%x%` → `contains`, `x%` → `startsWith`, `%x` → `endsWith`, and a wildcard-free value
|
|
7
|
+
* collapses to `equals` (matching SQL `LIKE 'x'` semantics).
|
|
8
|
+
* @param value - The raw LIKE pattern.
|
|
9
|
+
* @returns A Prisma string-filter object.
|
|
10
|
+
*/
|
|
11
|
+
function likeToPrisma(value) {
|
|
12
|
+
const text = String(value ?? '');
|
|
13
|
+
const lead = text.startsWith('%');
|
|
14
|
+
const trail = text.endsWith('%');
|
|
15
|
+
const inner = text.replace(/^%/, '').replace(/%$/, '');
|
|
16
|
+
if (lead && trail)
|
|
17
|
+
return { contains: inner };
|
|
18
|
+
if (trail)
|
|
19
|
+
return { startsWith: inner };
|
|
20
|
+
if (lead)
|
|
21
|
+
return { endsWith: inner };
|
|
22
|
+
return { equals: text };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Converts a single {@link IQueryFilter} comparison into a Prisma `where` fragment for one field.
|
|
26
|
+
*
|
|
27
|
+
* Most comparisons map to `{ [field]: { <op>: value } }`; `NOT BETWEEN` and `NOT LIKE`
|
|
28
|
+
* expand to `OR` / `NOT` groups. This is the only place comparison semantics live, so the
|
|
29
|
+
* delegate query path and the (validated) AST stay in lockstep.
|
|
30
|
+
*
|
|
31
|
+
* @param filter - The filter whose `field`, `comparison`, `value1`, and `value2` are read.
|
|
32
|
+
* @returns A Prisma `where` fragment expressing the comparison.
|
|
33
|
+
*/
|
|
34
|
+
function comparisonToPrisma(filter) {
|
|
35
|
+
const field = filter.field;
|
|
36
|
+
const v1 = filter.value1;
|
|
37
|
+
const v2 = filter.value2;
|
|
38
|
+
const comparison = (filter.comparison?.toUpperCase() ?? '=');
|
|
39
|
+
switch (comparison) {
|
|
40
|
+
case SqlComparison.Equal:
|
|
41
|
+
return { [field]: { equals: v1 } };
|
|
42
|
+
case SqlComparison.NotEqual:
|
|
43
|
+
return { [field]: { not: v1 } };
|
|
44
|
+
case SqlComparison.GreaterThan:
|
|
45
|
+
return { [field]: { gt: v1 } };
|
|
46
|
+
case SqlComparison.GreaterThanOrEqual:
|
|
47
|
+
return { [field]: { gte: v1 } };
|
|
48
|
+
case SqlComparison.LessThan:
|
|
49
|
+
return { [field]: { lt: v1 } };
|
|
50
|
+
case SqlComparison.LessThanOrEqual:
|
|
51
|
+
return { [field]: { lte: v1 } };
|
|
52
|
+
case SqlComparison.In:
|
|
53
|
+
return { [field]: { in: Array.isArray(v1) ? v1 : [v1] } };
|
|
54
|
+
case SqlComparison.NotIn:
|
|
55
|
+
return { [field]: { notIn: Array.isArray(v1) ? v1 : [v1] } };
|
|
56
|
+
case SqlComparison.Between:
|
|
57
|
+
return { [field]: { gte: v1, lte: v2 } };
|
|
58
|
+
case SqlComparison.NotBetween:
|
|
59
|
+
return { OR: [{ [field]: { lt: v1 } }, { [field]: { gt: v2 } }] };
|
|
60
|
+
case SqlComparison.IsNull:
|
|
61
|
+
return { [field]: null };
|
|
62
|
+
case SqlComparison.IsNotNull:
|
|
63
|
+
return { [field]: { not: null } };
|
|
64
|
+
case SqlComparison.Contains:
|
|
65
|
+
return { [field]: { contains: String(v1 ?? '') } };
|
|
66
|
+
case SqlComparison.StartsWith:
|
|
67
|
+
return { [field]: { startsWith: String(v1 ?? '') } };
|
|
68
|
+
case SqlComparison.EndsWith:
|
|
69
|
+
return { [field]: { endsWith: String(v1 ?? '') } };
|
|
70
|
+
case SqlComparison.Like:
|
|
71
|
+
return { [field]: likeToPrisma(v1) };
|
|
72
|
+
case SqlComparison.NotLike:
|
|
73
|
+
return { NOT: { [field]: likeToPrisma(v1) } };
|
|
74
|
+
default:
|
|
75
|
+
return { [field]: { equals: v1 } };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Translates one filter node into a Prisma fragment. When the node has nested `children`,
|
|
80
|
+
* its own condition is combined with the (parenthesised) children group using the node's
|
|
81
|
+
* `operator` — so `{ ...cond, operator: 'OR', children }` becomes `cond OR (children)` and
|
|
82
|
+
* `operator: 'AND'` (or omitted) becomes `cond AND (children)`, exactly as the original SQL
|
|
83
|
+
* builder rendered it.
|
|
84
|
+
* @param filter - The filter node.
|
|
85
|
+
* @returns The Prisma fragment for this node (and its children, if any).
|
|
86
|
+
*/
|
|
87
|
+
function nodeToPrisma(filter) {
|
|
88
|
+
const self = comparisonToPrisma(filter);
|
|
89
|
+
if (filter.children?.length) {
|
|
90
|
+
const childWhere = astToPrismaWhere(filter.children);
|
|
91
|
+
return filter.operator?.toUpperCase() === SqlOperator.Or
|
|
92
|
+
? { OR: [self, childWhere] }
|
|
93
|
+
: { AND: [self, childWhere] };
|
|
94
|
+
}
|
|
95
|
+
return self;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Translates a validated query-builder WHERE tree ({@link IQueryFilter}[]) into a Prisma
|
|
99
|
+
* `where` object.
|
|
100
|
+
*
|
|
101
|
+
* Logical precedence matches SQL: each filter's `operator` joins it to the *next* sibling,
|
|
102
|
+
* `AND` binds tighter than `OR`, so the list is split into OR-separated groups of AND-runs.
|
|
103
|
+
* A single condition is returned bare; an all-AND list as `{ AND: [...] }`; mixed operators
|
|
104
|
+
* as `{ OR: [{ AND: [...] }, ...] }`.
|
|
105
|
+
*
|
|
106
|
+
* This runs **after** {@link validateAdvancedQuery}, so every field name and comparison has
|
|
107
|
+
* already been checked — the 4xx errors are raised there, never inside Prisma.
|
|
108
|
+
*
|
|
109
|
+
* @param where - The validated filter list (may be empty/undefined).
|
|
110
|
+
* @returns A Prisma `where` object (empty `{}` when there are no filters).
|
|
111
|
+
*/
|
|
112
|
+
export function astToPrismaWhere(where) {
|
|
113
|
+
if (!where?.length)
|
|
114
|
+
return {};
|
|
115
|
+
const groups = [[]];
|
|
116
|
+
where.forEach((filter, index) => {
|
|
117
|
+
groups[groups.length - 1].push(nodeToPrisma(filter));
|
|
118
|
+
// A node with children has consumed its operator to join self↔children, so it does not
|
|
119
|
+
// also split sibling groups; only a childless node's `OR` starts a new OR-group.
|
|
120
|
+
const joinsWithOr = !filter.children?.length && filter.operator?.toUpperCase() === SqlOperator.Or;
|
|
121
|
+
if (joinsWithOr && index < where.length - 1)
|
|
122
|
+
groups.push([]);
|
|
123
|
+
});
|
|
124
|
+
const andify = (group) => group.length === 1 ? group[0] : { AND: group };
|
|
125
|
+
if (groups.length === 1)
|
|
126
|
+
return andify(groups[0]);
|
|
127
|
+
return { OR: groups.map(andify) };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Converts the AST `orderBy` ({@link ISort}[]) into a Prisma `orderBy` array.
|
|
131
|
+
* @param orderBy - Sort expressions from the query AST.
|
|
132
|
+
* @returns A Prisma `orderBy` array, or `undefined` when there are no sorts.
|
|
133
|
+
*/
|
|
134
|
+
export function astToPrismaOrderBy(orderBy) {
|
|
135
|
+
if (!orderBy?.length)
|
|
136
|
+
return undefined;
|
|
137
|
+
return orderBy.map((sort) => ({
|
|
138
|
+
[sort.field]: sort.order.toUpperCase() === SqlOrder.DESC ? 'desc' : 'asc'
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { CreatePrismaResourcesOptions } from './types.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Creates resource definitions for Prisma models based on the provided schema and options.
|
|
5
5
|
* @param prismaClient An instance of the Prisma client to be used for database operations.
|
|
6
|
-
* @param schema An array of model definitions, where each model includes its name
|
|
6
|
+
* @param schema An array of model definitions, where each model includes its name and fields.
|
|
7
7
|
* @param options An optional configuration object that allows customization of the generated resources, including model-specific options, default limits, and permissions.
|
|
8
8
|
* @returns An array of resource definitions that can be used to set up API endpoints or other data access layers based on the Prisma models.
|
|
9
9
|
*/
|
|
@@ -12,4 +12,3 @@ export declare function createPrismaResources(prismaClient: object, schema: Read
|
|
|
12
12
|
dbName?: string | null;
|
|
13
13
|
fields: ModelField[];
|
|
14
14
|
}>, options?: CreatePrismaResourcesOptions): ResourceDefinition[];
|
|
15
|
-
//# sourceMappingURL=createPrismaResources.d.ts.map
|