@woltz/rich-domain-drizzle 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/dist/cjs/batch-executor.d.ts +30 -0
- package/dist/cjs/batch-executor.d.ts.map +1 -0
- package/dist/cjs/batch-executor.js +201 -0
- package/dist/cjs/batch-executor.js.map +1 -0
- package/dist/cjs/errors.d.ts +31 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +84 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.d.ts +8 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +34 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/mappers/to-domain.d.ts +11 -0
- package/dist/cjs/mappers/to-domain.d.ts.map +1 -0
- package/dist/cjs/mappers/to-domain.js +15 -0
- package/dist/cjs/mappers/to-domain.js.map +1 -0
- package/dist/cjs/mappers/to-persistence.d.ts +47 -0
- package/dist/cjs/mappers/to-persistence.d.ts.map +1 -0
- package/dist/cjs/mappers/to-persistence.js +69 -0
- package/dist/cjs/mappers/to-persistence.js.map +1 -0
- package/dist/cjs/query-builder.d.ts +24 -0
- package/dist/cjs/query-builder.d.ts.map +1 -0
- package/dist/cjs/query-builder.js +146 -0
- package/dist/cjs/query-builder.js.map +1 -0
- package/dist/cjs/repository.d.ts +46 -0
- package/dist/cjs/repository.d.ts.map +1 -0
- package/dist/cjs/repository.js +192 -0
- package/dist/cjs/repository.js.map +1 -0
- package/dist/cjs/unit-of-work.d.ts +49 -0
- package/dist/cjs/unit-of-work.d.ts.map +1 -0
- package/dist/cjs/unit-of-work.js +94 -0
- package/dist/cjs/unit-of-work.js.map +1 -0
- package/dist/esm/batch-executor.d.ts +30 -0
- package/dist/esm/batch-executor.d.ts.map +1 -0
- package/dist/esm/batch-executor.js +196 -0
- package/dist/esm/batch-executor.js.map +1 -0
- package/dist/esm/errors.d.ts +31 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +75 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/mappers/to-domain.d.ts +11 -0
- package/dist/esm/mappers/to-domain.d.ts.map +1 -0
- package/dist/esm/mappers/to-domain.js +11 -0
- package/dist/esm/mappers/to-domain.js.map +1 -0
- package/dist/esm/mappers/to-persistence.d.ts +47 -0
- package/dist/esm/mappers/to-persistence.d.ts.map +1 -0
- package/dist/esm/mappers/to-persistence.js +65 -0
- package/dist/esm/mappers/to-persistence.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/query-builder.d.ts +24 -0
- package/dist/esm/query-builder.d.ts.map +1 -0
- package/dist/esm/query-builder.js +142 -0
- package/dist/esm/query-builder.js.map +1 -0
- package/dist/esm/repository.d.ts +46 -0
- package/dist/esm/repository.d.ts.map +1 -0
- package/dist/esm/repository.js +188 -0
- package/dist/esm/repository.js.map +1 -0
- package/dist/esm/unit-of-work.d.ts +49 -0
- package/dist/esm/unit-of-work.d.ts.map +1 -0
- package/dist/esm/unit-of-work.js +87 -0
- package/dist/esm/unit-of-work.js.map +1 -0
- package/dist/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/tsconfig.esm.tsbuildinfo +1 -0
- package/dist/tsconfig.types.tsbuildinfo +1 -0
- package/dist/types/batch-executor.d.ts +30 -0
- package/dist/types/batch-executor.d.ts.map +1 -0
- package/dist/types/errors.d.ts +31 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/mappers/to-domain.d.ts +11 -0
- package/dist/types/mappers/to-domain.d.ts.map +1 -0
- package/dist/types/mappers/to-persistence.d.ts +47 -0
- package/dist/types/mappers/to-persistence.d.ts.map +1 -0
- package/dist/types/query-builder.d.ts +24 -0
- package/dist/types/query-builder.d.ts.map +1 -0
- package/dist/types/repository.d.ts +46 -0
- package/dist/types/repository.d.ts.map +1 -0
- package/dist/types/unit-of-work.d.ts +49 -0
- package/dist/types/unit-of-work.d.ts.map +1 -0
- package/package.json +69 -0
- package/src/batch-executor.ts +317 -0
- package/src/errors.ts +78 -0
- package/src/index.ts +37 -0
- package/src/mappers/to-domain.ts +13 -0
- package/src/mappers/to-persistence.ts +101 -0
- package/src/query-builder.ts +217 -0
- package/src/repository.ts +252 -0
- package/src/unit-of-work.ts +123 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Mapper,
|
|
3
|
+
AggregateChanges,
|
|
4
|
+
EntitySchemaRegistry,
|
|
5
|
+
} from "@woltz/rich-domain";
|
|
6
|
+
import {
|
|
7
|
+
DrizzleClient,
|
|
8
|
+
DrizzleUnitOfWork,
|
|
9
|
+
UOWStorage,
|
|
10
|
+
Transactional,
|
|
11
|
+
} from "../unit-of-work";
|
|
12
|
+
import { DrizzleBatchExecutor } from "../batch-executor";
|
|
13
|
+
|
|
14
|
+
export abstract class DrizzleToPersistence<TDomain> extends Mapper<
|
|
15
|
+
TDomain,
|
|
16
|
+
void
|
|
17
|
+
> {
|
|
18
|
+
constructor(protected readonly uow: DrizzleUnitOfWork) {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Schema registry for field mapping (entity → table, field → column).
|
|
24
|
+
*/
|
|
25
|
+
protected abstract readonly registry: EntitySchemaRegistry;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map of entity names to Drizzle table objects.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* protected readonly tableMap = new Map([
|
|
33
|
+
* ["User", usersTable],
|
|
34
|
+
* ["Post", postsTable],
|
|
35
|
+
* ]);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
protected abstract readonly tableMap: Map<string, any>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the raw db instance.
|
|
42
|
+
*/
|
|
43
|
+
protected abstract getDb(): DrizzleClient;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get current context (transaction client or raw db).
|
|
47
|
+
*/
|
|
48
|
+
protected get context(): DrizzleClient {
|
|
49
|
+
const ctx = UOWStorage.getStore()?.ctx;
|
|
50
|
+
return ctx?.client ?? this.getDb();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build persistence operations.
|
|
55
|
+
*/
|
|
56
|
+
async build(entity: TDomain): Promise<void> {
|
|
57
|
+
const isNew = (entity as any).isNew?.() ?? false;
|
|
58
|
+
|
|
59
|
+
if (isNew) {
|
|
60
|
+
await this.onCreate(entity);
|
|
61
|
+
} else {
|
|
62
|
+
await this.handleUpdate(entity);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handle new aggregate creation.
|
|
68
|
+
* Must be implemented by subclass.
|
|
69
|
+
*/
|
|
70
|
+
protected abstract onCreate(aggregate: TDomain): Promise<void>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handle aggregate update with changes.
|
|
74
|
+
* Default implementation uses DrizzleBatchExecutor.
|
|
75
|
+
* Subclass can override for custom logic.
|
|
76
|
+
*/
|
|
77
|
+
protected async onUpdate(
|
|
78
|
+
changes: AggregateChanges,
|
|
79
|
+
_aggregate: TDomain
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const executor = new DrizzleBatchExecutor({
|
|
82
|
+
registry: this.registry,
|
|
83
|
+
db: this.context,
|
|
84
|
+
tableMap: this.tableMap,
|
|
85
|
+
});
|
|
86
|
+
await executor.execute(changes);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Transactional()
|
|
90
|
+
private async handleUpdate(entity: TDomain): Promise<void> {
|
|
91
|
+
const changes = (entity as any).getChanges?.() as
|
|
92
|
+
| AggregateChanges
|
|
93
|
+
| undefined;
|
|
94
|
+
|
|
95
|
+
if (!changes || changes.isEmpty()) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.onUpdate(changes, entity);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eq,
|
|
3
|
+
ne,
|
|
4
|
+
gt,
|
|
5
|
+
gte,
|
|
6
|
+
lt,
|
|
7
|
+
lte,
|
|
8
|
+
ilike,
|
|
9
|
+
like,
|
|
10
|
+
inArray,
|
|
11
|
+
notInArray,
|
|
12
|
+
isNull,
|
|
13
|
+
isNotNull,
|
|
14
|
+
between,
|
|
15
|
+
and,
|
|
16
|
+
or,
|
|
17
|
+
asc,
|
|
18
|
+
desc,
|
|
19
|
+
SQL,
|
|
20
|
+
} from "drizzle-orm";
|
|
21
|
+
import { Criteria } from "@woltz/rich-domain";
|
|
22
|
+
import { DrizzleAdapterError } from "./errors";
|
|
23
|
+
|
|
24
|
+
export type SearchableField<T> =
|
|
25
|
+
| keyof T
|
|
26
|
+
| { field: string; caseSensitive?: boolean };
|
|
27
|
+
|
|
28
|
+
type OperatorFn = (col: any, val: any) => SQL;
|
|
29
|
+
|
|
30
|
+
const OPERATOR_MAP: Record<string, OperatorFn> = {
|
|
31
|
+
equals: (col, val) => eq(col, val),
|
|
32
|
+
notEquals: (col, val) => ne(col, val),
|
|
33
|
+
greaterThan: (col, val) => gt(col, val),
|
|
34
|
+
greaterThanOrEqual: (col, val) => gte(col, val),
|
|
35
|
+
lessThan: (col, val) => lt(col, val),
|
|
36
|
+
lessThanOrEqual: (col, val) => lte(col, val),
|
|
37
|
+
contains: (col, val) => ilike(col, `%${val}%`),
|
|
38
|
+
startsWith: (col, val) => ilike(col, `${val}%`),
|
|
39
|
+
endsWith: (col, val) => ilike(col, `%${val}`),
|
|
40
|
+
in: (col, val) => inArray(col, val),
|
|
41
|
+
notIn: (col, val) => notInArray(col, val),
|
|
42
|
+
isNull: (col) => isNull(col),
|
|
43
|
+
isNotNull: (col) => isNotNull(col),
|
|
44
|
+
between: (col, val) => between(col, val[0], val[1]),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export class DrizzleQueryBuilder {
|
|
48
|
+
/**
|
|
49
|
+
* Apply Criteria to a Drizzle select query.
|
|
50
|
+
*
|
|
51
|
+
* @param criteria - The Criteria instance with filters, orders, pagination, search
|
|
52
|
+
* @param table - The Drizzle table object (e.g., usersTable)
|
|
53
|
+
* @param searchableFields - Fields to search when criteria.hasSearch()
|
|
54
|
+
* @returns Object with { where, orderBy, limit, offset } ready for Drizzle query
|
|
55
|
+
*/
|
|
56
|
+
static apply<T>(
|
|
57
|
+
criteria: Criteria<T>,
|
|
58
|
+
table: any,
|
|
59
|
+
searchableFields?: SearchableField<any>[]
|
|
60
|
+
): {
|
|
61
|
+
where?: SQL;
|
|
62
|
+
orderBy?: SQL[];
|
|
63
|
+
limit?: number;
|
|
64
|
+
offset?: number;
|
|
65
|
+
} {
|
|
66
|
+
const result: {
|
|
67
|
+
where?: SQL;
|
|
68
|
+
orderBy?: SQL[];
|
|
69
|
+
limit?: number;
|
|
70
|
+
offset?: number;
|
|
71
|
+
} = {};
|
|
72
|
+
|
|
73
|
+
// Build filter conditions
|
|
74
|
+
const filterConditions: SQL[] = [];
|
|
75
|
+
for (const filter of criteria.getFilters()) {
|
|
76
|
+
if (filter.field.includes(".")) {
|
|
77
|
+
throw new DrizzleAdapterError(
|
|
78
|
+
`Filtering on relation field "${filter.field}" is not supported. ` +
|
|
79
|
+
`Drizzle requires explicit JOINs to filter across relations. ` +
|
|
80
|
+
`Add a custom method to your repository using the Drizzle SQL API instead.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (filter.options?.quantifier) {
|
|
85
|
+
throw new DrizzleAdapterError(
|
|
86
|
+
`Quantifier "${filter.options.quantifier}" on field "${filter.field}" is not supported. ` +
|
|
87
|
+
`Drizzle does not have a built-in some/every/none operator. ` +
|
|
88
|
+
`Add a custom method to your repository using EXISTS subqueries instead.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const col = DrizzleQueryBuilder.resolveColumn(table, filter.field);
|
|
93
|
+
if (col === undefined) {
|
|
94
|
+
throw new DrizzleAdapterError(
|
|
95
|
+
`Column "${filter.field}" not found on table. ` +
|
|
96
|
+
`Available columns: ${Object.keys(table)
|
|
97
|
+
.filter((k) => typeof table[k] === "object")
|
|
98
|
+
.join(", ")}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const operatorFn = OPERATOR_MAP[filter.operator];
|
|
103
|
+
if (!operatorFn) {
|
|
104
|
+
throw new DrizzleAdapterError(
|
|
105
|
+
`Operator "${filter.operator}" is not supported by the Drizzle adapter.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
filterConditions.push(operatorFn(col, filter.value));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build search conditions
|
|
113
|
+
let searchCondition: SQL | undefined;
|
|
114
|
+
if (
|
|
115
|
+
criteria.hasSearch() &&
|
|
116
|
+
searchableFields &&
|
|
117
|
+
searchableFields.length > 0
|
|
118
|
+
) {
|
|
119
|
+
const search = criteria.getSearch()!;
|
|
120
|
+
const searchConditions: SQL[] = [];
|
|
121
|
+
|
|
122
|
+
for (const searchField of searchableFields) {
|
|
123
|
+
let fieldName: string;
|
|
124
|
+
let caseSensitive = false;
|
|
125
|
+
|
|
126
|
+
if (typeof searchField === "object" && "field" in searchField) {
|
|
127
|
+
fieldName = searchField.field;
|
|
128
|
+
caseSensitive = searchField.caseSensitive ?? false;
|
|
129
|
+
} else {
|
|
130
|
+
fieldName = String(searchField);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (fieldName.includes(".")) {
|
|
134
|
+
throw new DrizzleAdapterError(
|
|
135
|
+
`Search field "${fieldName}" references a relation. ` +
|
|
136
|
+
`Drizzle does not support cross-table search via Criteria. ` +
|
|
137
|
+
`Use only top-level columns in getSearchableFields().`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const col = DrizzleQueryBuilder.resolveColumn(table, fieldName);
|
|
142
|
+
if (col === undefined) {
|
|
143
|
+
throw new DrizzleAdapterError(
|
|
144
|
+
`Search field "${fieldName}" not found on table. ` +
|
|
145
|
+
`Available columns: ${Object.keys(table)
|
|
146
|
+
.filter((k) => typeof table[k] === "object")
|
|
147
|
+
.join(", ")}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (caseSensitive) {
|
|
152
|
+
searchConditions.push(like(col, `%${search}%`));
|
|
153
|
+
} else {
|
|
154
|
+
searchConditions.push(ilike(col, `%${search}%`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (searchConditions.length > 0) {
|
|
159
|
+
searchCondition =
|
|
160
|
+
searchConditions.length === 1
|
|
161
|
+
? searchConditions[0]
|
|
162
|
+
: or(...searchConditions)!;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Combine filter and search conditions
|
|
167
|
+
if (filterConditions.length > 0 || searchCondition) {
|
|
168
|
+
const allConditions: SQL[] = [...filterConditions];
|
|
169
|
+
if (searchCondition) allConditions.push(searchCondition);
|
|
170
|
+
|
|
171
|
+
if (allConditions.length === 1) {
|
|
172
|
+
result.where = allConditions[0];
|
|
173
|
+
} else {
|
|
174
|
+
result.where = and(...allConditions)!;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build order conditions
|
|
179
|
+
const orders = criteria.getOrders();
|
|
180
|
+
if (orders.length > 0) {
|
|
181
|
+
result.orderBy = orders.map((o) => {
|
|
182
|
+
if (o.field.includes(".")) {
|
|
183
|
+
throw new DrizzleAdapterError(
|
|
184
|
+
`Ordering by relation field "${o.field}" is not supported. ` +
|
|
185
|
+
`Drizzle requires explicit JOINs to order across relations. ` +
|
|
186
|
+
`Add a custom method to your repository using the Drizzle SQL API instead.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const col = DrizzleQueryBuilder.resolveColumn(table, o.field);
|
|
191
|
+
if (col === undefined) {
|
|
192
|
+
throw new DrizzleAdapterError(
|
|
193
|
+
`Order field "${o.field}" not found on table. ` +
|
|
194
|
+
`Available columns: ${Object.keys(table)
|
|
195
|
+
.filter((k) => typeof table[k] === "object")
|
|
196
|
+
.join(", ")}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return o.direction === "desc" ? desc(col) : asc(col);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build pagination
|
|
205
|
+
const pagination = criteria.getPagination();
|
|
206
|
+
if (pagination) {
|
|
207
|
+
result.limit = pagination.limit;
|
|
208
|
+
result.offset = pagination.offset;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
static resolveColumn(table: any, fieldPath: string): any {
|
|
215
|
+
return table[fieldPath];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { eq, inArray, count, SQL } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
Aggregate,
|
|
4
|
+
Repository,
|
|
5
|
+
Mapper,
|
|
6
|
+
Criteria,
|
|
7
|
+
PaginatedResult,
|
|
8
|
+
} from "@woltz/rich-domain";
|
|
9
|
+
import { DrizzleClient, DrizzleUnitOfWork, UOWStorage } from "./unit-of-work";
|
|
10
|
+
import { NoRecordsAffectedError } from "./errors";
|
|
11
|
+
import { DrizzleToPersistence } from "./mappers/to-persistence";
|
|
12
|
+
import { DrizzleQueryBuilder, SearchableField } from "./query-builder";
|
|
13
|
+
|
|
14
|
+
export interface DrizzleRepositoryConfig<TDomain, TPersistence> {
|
|
15
|
+
db: DrizzleClient;
|
|
16
|
+
table: any;
|
|
17
|
+
toDomainMapper: Mapper<TPersistence, TDomain>;
|
|
18
|
+
toPersistenceMapper: DrizzleToPersistence<TDomain>;
|
|
19
|
+
uow: DrizzleUnitOfWork;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export abstract class DrizzleRepository<
|
|
23
|
+
TDomain extends Aggregate<any>,
|
|
24
|
+
TPersistence,
|
|
25
|
+
> extends Repository<TDomain> {
|
|
26
|
+
protected readonly db: DrizzleClient;
|
|
27
|
+
protected readonly table: any;
|
|
28
|
+
protected readonly toDomainMapper: Mapper<TPersistence, TDomain>;
|
|
29
|
+
protected readonly toPersistenceMapper: DrizzleToPersistence<TDomain>;
|
|
30
|
+
protected readonly uow: DrizzleUnitOfWork;
|
|
31
|
+
|
|
32
|
+
constructor(config: DrizzleRepositoryConfig<TDomain, TPersistence>) {
|
|
33
|
+
super();
|
|
34
|
+
this.db = config.db;
|
|
35
|
+
this.table = config.table;
|
|
36
|
+
this.toDomainMapper = config.toDomainMapper;
|
|
37
|
+
this.toPersistenceMapper = config.toPersistenceMapper;
|
|
38
|
+
this.uow = config.uow;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns tx from UOWStorage if inside a transaction, otherwise the raw db.
|
|
43
|
+
*/
|
|
44
|
+
protected get context(): DrizzleClient {
|
|
45
|
+
const ctx = UOWStorage.getStore()?.ctx;
|
|
46
|
+
return ctx?.client ?? this.db;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The table name string used by EntitySchemaRegistry and db.query accessor.
|
|
51
|
+
*/
|
|
52
|
+
protected abstract get model(): string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Search conditions for full-text search via Criteria.search().
|
|
56
|
+
*/
|
|
57
|
+
protected abstract getSearchableFields(): SearchableField<TPersistence>[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Relations to include when fetching (for Drizzle relational query API).
|
|
61
|
+
*/
|
|
62
|
+
protected getDefaultRelations(): Record<string, any> {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async find(criteria: Criteria<TDomain>): Promise<PaginatedResult<TDomain>> {
|
|
67
|
+
const { where, orderBy, limit, offset } = DrizzleQueryBuilder.apply(
|
|
68
|
+
criteria,
|
|
69
|
+
this.table,
|
|
70
|
+
this.getSearchableFields()
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const queryModel = this.context.query?.[this.model];
|
|
74
|
+
|
|
75
|
+
let data: TPersistence[];
|
|
76
|
+
let total: number;
|
|
77
|
+
|
|
78
|
+
if (queryModel) {
|
|
79
|
+
[data, total] = await Promise.all([
|
|
80
|
+
queryModel.findMany({
|
|
81
|
+
where,
|
|
82
|
+
orderBy,
|
|
83
|
+
limit,
|
|
84
|
+
offset,
|
|
85
|
+
with: this.getDefaultRelations(),
|
|
86
|
+
}),
|
|
87
|
+
this.context
|
|
88
|
+
.select({ value: count() })
|
|
89
|
+
.from(this.table)
|
|
90
|
+
.where(where)
|
|
91
|
+
.then((r: any[]) => Number(r[0]?.value ?? 0)),
|
|
92
|
+
]);
|
|
93
|
+
} else {
|
|
94
|
+
const selectQuery = this.context.select().from(this.table).$dynamic();
|
|
95
|
+
|
|
96
|
+
if (where) selectQuery.where(where);
|
|
97
|
+
if (orderBy && orderBy.length > 0) selectQuery.orderBy(...orderBy);
|
|
98
|
+
if (limit !== undefined) selectQuery.limit(limit);
|
|
99
|
+
if (offset !== undefined) selectQuery.offset(offset);
|
|
100
|
+
|
|
101
|
+
const countQuery = this.context
|
|
102
|
+
.select({ value: count() })
|
|
103
|
+
.from(this.table);
|
|
104
|
+
if (where) countQuery.where(where);
|
|
105
|
+
|
|
106
|
+
[data, [{ value: total }]] = await Promise.all([selectQuery, countQuery]);
|
|
107
|
+
total = Number(total ?? 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const toDomain: TDomain[] = (data as any[]).map((item) =>
|
|
111
|
+
this.toDomainMapper.build(item)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
this.markArrayOfAggregateWithClean(toDomain);
|
|
115
|
+
|
|
116
|
+
return PaginatedResult.create(toDomain, criteria.getPagination(), total);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async findById(id: string): Promise<TDomain | null> {
|
|
120
|
+
const queryModel = this.context.query?.[this.model];
|
|
121
|
+
|
|
122
|
+
let data: any;
|
|
123
|
+
|
|
124
|
+
if (queryModel) {
|
|
125
|
+
data = await queryModel.findFirst({
|
|
126
|
+
where: eq(this.table.id, id),
|
|
127
|
+
with: this.getDefaultRelations(),
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
const rows = await this.context
|
|
131
|
+
.select()
|
|
132
|
+
.from(this.table)
|
|
133
|
+
.where(eq(this.table.id, id))
|
|
134
|
+
.limit(1);
|
|
135
|
+
data = rows[0] ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!data) return null;
|
|
139
|
+
|
|
140
|
+
const result = this.toDomainMapper.build(data);
|
|
141
|
+
|
|
142
|
+
if (result instanceof Aggregate) {
|
|
143
|
+
result.markAsClean();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async findManyByIds(ids: string[]): Promise<TDomain[]> {
|
|
150
|
+
if (ids.length === 0) return [];
|
|
151
|
+
|
|
152
|
+
const queryModel = this.context.query?.[this.model];
|
|
153
|
+
|
|
154
|
+
let data: any[];
|
|
155
|
+
|
|
156
|
+
if (queryModel) {
|
|
157
|
+
data = await queryModel.findMany({
|
|
158
|
+
where: inArray(this.table.id, ids),
|
|
159
|
+
with: this.getDefaultRelations(),
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
data = await this.context
|
|
163
|
+
.select()
|
|
164
|
+
.from(this.table)
|
|
165
|
+
.where(inArray(this.table.id, ids));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const toDomain: TDomain[] = data.map((item: any) =>
|
|
169
|
+
this.toDomainMapper.build(item)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
this.markArrayOfAggregateWithClean(toDomain);
|
|
173
|
+
|
|
174
|
+
return toDomain;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async count(criteria?: Criteria<TDomain>): Promise<number> {
|
|
178
|
+
let where: SQL | undefined;
|
|
179
|
+
|
|
180
|
+
if (criteria) {
|
|
181
|
+
({ where } = DrizzleQueryBuilder.apply(
|
|
182
|
+
criteria,
|
|
183
|
+
this.table,
|
|
184
|
+
this.getSearchableFields()
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const query = this.context.select({ value: count() }).from(this.table);
|
|
189
|
+
if (where) query.where(where);
|
|
190
|
+
|
|
191
|
+
const result = await query;
|
|
192
|
+
return Number(result[0]?.value ?? 0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async exists(id: string): Promise<boolean> {
|
|
196
|
+
const result = await this.context
|
|
197
|
+
.select({ value: count() })
|
|
198
|
+
.from(this.table)
|
|
199
|
+
.where(eq(this.table.id, id));
|
|
200
|
+
return Number(result[0]?.value ?? 0) > 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async save(entity: TDomain): Promise<void> {
|
|
204
|
+
await this.toPersistenceMapper.build(entity);
|
|
205
|
+
entity.markAsPersisted();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async delete(entity: TDomain): Promise<void> {
|
|
209
|
+
const id = entity.id.value;
|
|
210
|
+
try {
|
|
211
|
+
const result = await this.context
|
|
212
|
+
.delete(this.table)
|
|
213
|
+
.where(eq(this.table.id, id))
|
|
214
|
+
.returning({ id: this.table.id });
|
|
215
|
+
|
|
216
|
+
if (!result || result.length === 0) {
|
|
217
|
+
throw new NoRecordsAffectedError("Delete", this.model, String(id));
|
|
218
|
+
}
|
|
219
|
+
} catch (error: any) {
|
|
220
|
+
if (error instanceof NoRecordsAffectedError) throw error;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async deleteById(id: string): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
const result = await this.context
|
|
228
|
+
.delete(this.table)
|
|
229
|
+
.where(eq(this.table.id, id))
|
|
230
|
+
.returning({ id: this.table.id });
|
|
231
|
+
|
|
232
|
+
if (!result || result.length === 0) {
|
|
233
|
+
throw new NoRecordsAffectedError("Delete", this.model, id);
|
|
234
|
+
}
|
|
235
|
+
} catch (error: any) {
|
|
236
|
+
if (error instanceof NoRecordsAffectedError) throw error;
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async transaction<T>(work: () => Promise<T>): Promise<T> {
|
|
242
|
+
return this.uow.transaction(work);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private markArrayOfAggregateWithClean(entities: TDomain[]): void {
|
|
246
|
+
for (const entity of entities) {
|
|
247
|
+
if (entity instanceof Aggregate) {
|
|
248
|
+
entity.markAsClean();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drizzle database instance type.
|
|
5
|
+
* Generic to support pg, mysql, sqlite, and other drivers.
|
|
6
|
+
*/
|
|
7
|
+
export type DrizzleClient = {
|
|
8
|
+
transaction: <T>(fn: (tx: any) => Promise<T>) => Promise<T>;
|
|
9
|
+
query: Record<string, any>;
|
|
10
|
+
select: (...args: any[]) => any;
|
|
11
|
+
insert: (table: any) => any;
|
|
12
|
+
update: (table: any) => any;
|
|
13
|
+
delete: (table: any) => any;
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DrizzleTransactionClient = DrizzleClient;
|
|
18
|
+
|
|
19
|
+
export class DrizzleTransactionContext {
|
|
20
|
+
constructor(public readonly client: DrizzleTransactionClient) {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const UOWStorage = new AsyncLocalStorage<{
|
|
24
|
+
ctx: DrizzleTransactionContext | null;
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
export class DrizzleUnitOfWork {
|
|
28
|
+
constructor(private readonly db: DrizzleClient) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get current transaction context (if any).
|
|
32
|
+
*/
|
|
33
|
+
getCurrentContext(): DrizzleTransactionContext | null {
|
|
34
|
+
return UOWStorage.getStore()?.ctx ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if currently inside a transaction.
|
|
39
|
+
*/
|
|
40
|
+
isInTransaction(): boolean {
|
|
41
|
+
return this.getCurrentContext() !== null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Execute work inside a transaction.
|
|
46
|
+
* If already in a transaction, reuses the existing context (idempotent nesting).
|
|
47
|
+
*/
|
|
48
|
+
async transaction<T>(work: () => Promise<T>): Promise<T> {
|
|
49
|
+
if (this.isInTransaction()) {
|
|
50
|
+
return work();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return this.db.transaction(async (tx: any) => {
|
|
54
|
+
const ctx = new DrizzleTransactionContext(tx);
|
|
55
|
+
|
|
56
|
+
return UOWStorage.run({ ctx }, async () => {
|
|
57
|
+
return await work();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decorator that wraps a method in a transaction.
|
|
65
|
+
* If already inside a transaction, reuses the existing one.
|
|
66
|
+
*/
|
|
67
|
+
export function Transactional(inputUow?: DrizzleUnitOfWork): MethodDecorator {
|
|
68
|
+
return function (
|
|
69
|
+
_target: any,
|
|
70
|
+
_propertyKey: string | symbol,
|
|
71
|
+
descriptor: PropertyDescriptor
|
|
72
|
+
) {
|
|
73
|
+
const original = descriptor.value;
|
|
74
|
+
|
|
75
|
+
descriptor.value = async function (this: any, ...args: any[]) {
|
|
76
|
+
if (UOWStorage.getStore()?.ctx) {
|
|
77
|
+
return original.apply(this, args);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const uow = inputUow ?? findUnitOfWork(this);
|
|
81
|
+
|
|
82
|
+
if (!uow) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`@Transactional: DrizzleUnitOfWork not found in ${this.constructor.name} instance. ` +
|
|
85
|
+
"Ensure your class has a 'uow' property. eg: constructor(private readonly uow: DrizzleUnitOfWork) {}"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return uow.transaction(() => original.apply(this, args));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return descriptor;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find DrizzleUnitOfWork in instance.
|
|
98
|
+
*/
|
|
99
|
+
function findUnitOfWork(instance: any): DrizzleUnitOfWork | null {
|
|
100
|
+
if (instance.uow instanceof DrizzleUnitOfWork) {
|
|
101
|
+
return instance.uow;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (instance._uow instanceof DrizzleUnitOfWork) {
|
|
105
|
+
return instance._uow;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const key of Object.keys(instance)) {
|
|
109
|
+
const value = instance[key];
|
|
110
|
+
if (value instanceof DrizzleUnitOfWork) {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Helper to get current transaction client from anywhere.
|
|
120
|
+
*/
|
|
121
|
+
export function getCurrentDrizzleContext(): DrizzleTransactionClient | null {
|
|
122
|
+
return UOWStorage.getStore()?.ctx?.client ?? null;
|
|
123
|
+
}
|