@onivoro/server-typeorm-postgres 24.30.12 → 24.30.13
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 +242 -511
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,39 +1,63 @@
|
|
|
1
1
|
# @onivoro/server-typeorm-postgres
|
|
2
2
|
|
|
3
|
-
A TypeORM PostgreSQL integration library providing repository patterns, SQL generation utilities, migration base classes, and
|
|
3
|
+
A TypeORM PostgreSQL integration library providing a NestJS module configuration, enhanced repository patterns, SQL generation utilities, migration base classes, and custom decorators for PostgreSQL and Amazon Redshift applications.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @onivoro/server-typeorm-postgres
|
|
8
|
+
npm install @onivoro/server-typeorm-postgres typeorm-naming-strategies
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Overview
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
13
|
+
This library provides:
|
|
14
|
+
- **NestJS Module**: Dynamic module configuration for TypeORM with PostgreSQL
|
|
15
|
+
- **Enhanced Repositories**: `TypeOrmRepository` with additional methods beyond standard TypeORM
|
|
16
|
+
- **Redshift Repository**: Specialized repository for Amazon Redshift with optimizations
|
|
17
|
+
- **SQL Writer**: Static utility for generating PostgreSQL DDL statements
|
|
18
|
+
- **Migration Base Classes**: Simplified classes for common migration operations
|
|
19
|
+
- **Custom Decorators**: Simplified table and column decorators with OpenAPI integration
|
|
20
|
+
- **Pagination Support**: Abstract base class for implementing paginated queries
|
|
21
|
+
- **Utility Functions**: Helper functions for pagination, date queries, and data manipulation
|
|
20
22
|
|
|
21
|
-
##
|
|
22
|
-
|
|
23
|
-
### Module Import
|
|
23
|
+
## Module Setup
|
|
24
24
|
|
|
25
25
|
```typescript
|
|
26
26
|
import { ServerTypeormPostgresModule } from '@onivoro/server-typeorm-postgres';
|
|
27
|
+
import { User, Product } from './entities';
|
|
27
28
|
|
|
28
29
|
@Module({
|
|
29
30
|
imports: [
|
|
30
|
-
ServerTypeormPostgresModule
|
|
31
|
-
|
|
31
|
+
ServerTypeormPostgresModule.configure(
|
|
32
|
+
[UserRepository, ProductRepository], // Injectables
|
|
33
|
+
[User, Product], // Entities
|
|
34
|
+
{
|
|
35
|
+
host: 'localhost',
|
|
36
|
+
port: 5432,
|
|
37
|
+
username: 'postgres',
|
|
38
|
+
password: 'password',
|
|
39
|
+
database: 'myapp',
|
|
40
|
+
ca: process.env.DB_CA, // Optional SSL certificate
|
|
41
|
+
synchronize: false, // Never true in production
|
|
42
|
+
logging: false,
|
|
43
|
+
schema: 'public' // Optional schema
|
|
44
|
+
},
|
|
45
|
+
'default' // Connection name
|
|
46
|
+
)
|
|
47
|
+
]
|
|
32
48
|
})
|
|
33
49
|
export class AppModule {}
|
|
34
50
|
```
|
|
35
51
|
|
|
36
|
-
|
|
52
|
+
The module:
|
|
53
|
+
- Provides `DataSource` and `EntityManager` for injection
|
|
54
|
+
- Caches data sources by name to prevent duplicate connections
|
|
55
|
+
- Uses `SnakeNamingStrategy` for column naming
|
|
56
|
+
- Supports SSL connections with certificate
|
|
57
|
+
|
|
58
|
+
## Entity Definition with Custom Decorators
|
|
59
|
+
|
|
60
|
+
The library provides simplified decorators that combine TypeORM and OpenAPI functionality:
|
|
37
61
|
|
|
38
62
|
```typescript
|
|
39
63
|
import {
|
|
@@ -48,34 +72,30 @@ export class User {
|
|
|
48
72
|
@PrimaryTableColumn()
|
|
49
73
|
id: number;
|
|
50
74
|
|
|
51
|
-
@TableColumn({ type: 'varchar'
|
|
75
|
+
@TableColumn({ type: 'varchar' })
|
|
52
76
|
email: string;
|
|
53
77
|
|
|
54
|
-
@TableColumn({ type: 'varchar'
|
|
78
|
+
@TableColumn({ type: 'varchar' })
|
|
55
79
|
firstName: string;
|
|
56
80
|
|
|
57
81
|
@NullableTableColumn({ type: 'timestamp' })
|
|
58
82
|
lastLoginAt?: Date;
|
|
59
83
|
|
|
60
|
-
@TableColumn({ type: 'boolean'
|
|
84
|
+
@TableColumn({ type: 'boolean' })
|
|
61
85
|
isActive: boolean;
|
|
62
86
|
|
|
63
|
-
@TableColumn({ type: 'jsonb'
|
|
87
|
+
@TableColumn({ type: 'jsonb' })
|
|
64
88
|
metadata: Record<string, any>;
|
|
65
|
-
|
|
66
|
-
@TableColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
|
67
|
-
createdAt: Date;
|
|
68
|
-
|
|
69
|
-
@NullableTableColumn({ type: 'timestamp' })
|
|
70
|
-
deletedAt?: Date;
|
|
71
89
|
}
|
|
72
90
|
```
|
|
73
91
|
|
|
92
|
+
**Important**: These decorators only accept the `type` property from TypeORM's `ColumnOptions`. For full control over column options, use TypeORM's decorators directly.
|
|
93
|
+
|
|
74
94
|
## Repository Classes
|
|
75
95
|
|
|
76
96
|
### TypeOrmRepository
|
|
77
97
|
|
|
78
|
-
Enhanced repository with
|
|
98
|
+
Enhanced repository with additional convenience methods:
|
|
79
99
|
|
|
80
100
|
```typescript
|
|
81
101
|
import { Injectable } from '@nestjs/common';
|
|
@@ -89,121 +109,101 @@ export class UserRepository extends TypeOrmRepository<User> {
|
|
|
89
109
|
super(User, entityManager);
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
//
|
|
93
|
-
async
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
where: { isActive: true }
|
|
112
|
+
// Available methods:
|
|
113
|
+
async findUsers() {
|
|
114
|
+
// getOne - throws if more than one result
|
|
115
|
+
const user = await this.getOne({ where: { id: 1 } });
|
|
116
|
+
|
|
117
|
+
// getMany - returns array
|
|
118
|
+
const activeUsers = await this.getMany({ where: { isActive: true } });
|
|
119
|
+
|
|
120
|
+
// getManyAndCount - returns [items, count]
|
|
121
|
+
const [users, total] = await this.getManyAndCount({
|
|
122
|
+
where: { isActive: true },
|
|
123
|
+
take: 10,
|
|
124
|
+
skip: 0
|
|
106
125
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
await this.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
await this.delete({ id });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async softDeleteUser(id: number): Promise<void> {
|
|
132
|
-
await this.softDelete({ id });
|
|
126
|
+
|
|
127
|
+
// postOne - insert and return
|
|
128
|
+
const newUser = await this.postOne({ email: 'test@example.com', firstName: 'Test' });
|
|
129
|
+
|
|
130
|
+
// postMany - bulk insert and return
|
|
131
|
+
const newUsers = await this.postMany([
|
|
132
|
+
{ email: 'user1@example.com', firstName: 'User1' },
|
|
133
|
+
{ email: 'user2@example.com', firstName: 'User2' }
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// patch - update using TypeORM's update() (doesn't trigger hooks)
|
|
137
|
+
await this.patch({ id: 1 }, { isActive: false });
|
|
138
|
+
|
|
139
|
+
// put - update using TypeORM's save() (triggers hooks)
|
|
140
|
+
await this.put({ id: 1 }, { isActive: false });
|
|
141
|
+
|
|
142
|
+
// delete - hard delete
|
|
143
|
+
await this.delete({ id: 1 });
|
|
144
|
+
|
|
145
|
+
// softDelete - soft delete
|
|
146
|
+
await this.softDelete({ id: 1 });
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
// Transaction support
|
|
136
|
-
async
|
|
137
|
-
const
|
|
138
|
-
|
|
150
|
+
async updateInTransaction(userId: number, data: Partial<User>, entityManager: EntityManager) {
|
|
151
|
+
const txRepo = this.forTransaction(entityManager);
|
|
152
|
+
await txRepo.patch({ id: userId }, data);
|
|
139
153
|
}
|
|
140
154
|
|
|
141
|
-
//
|
|
142
|
-
async
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return this.queryAndMap(query, [value]);
|
|
155
|
+
// Raw SQL with mapping
|
|
156
|
+
async customQuery() {
|
|
157
|
+
// query - returns raw results
|
|
158
|
+
const raw = await this.query('SELECT * FROM users WHERE created_at > $1', [new Date('2024-01-01')]);
|
|
159
|
+
|
|
160
|
+
// queryAndMap - maps results to entity type
|
|
161
|
+
const users = await this.queryAndMap('SELECT * FROM users WHERE active = $1', [true]);
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
//
|
|
152
|
-
async searchUsers(
|
|
164
|
+
// ILike helper for case-insensitive search
|
|
165
|
+
async searchUsers(term: string) {
|
|
153
166
|
const filters = this.buildWhereILike({
|
|
154
|
-
firstName:
|
|
155
|
-
|
|
156
|
-
email: searchTerm
|
|
167
|
+
firstName: term,
|
|
168
|
+
email: term
|
|
157
169
|
});
|
|
158
|
-
|
|
159
170
|
return this.getMany({ where: filters });
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
```
|
|
163
174
|
|
|
175
|
+
The repository also provides access to:
|
|
176
|
+
- `repo` - The underlying TypeORM repository
|
|
177
|
+
- `columns` - Metadata about entity columns
|
|
178
|
+
- `table` - Table name
|
|
179
|
+
- `schema` - Schema name
|
|
180
|
+
|
|
164
181
|
### TypeOrmPagingRepository
|
|
165
182
|
|
|
166
|
-
Abstract base class
|
|
183
|
+
Abstract base class requiring implementation of `getPage`:
|
|
167
184
|
|
|
168
185
|
```typescript
|
|
169
186
|
import { Injectable } from '@nestjs/common';
|
|
170
|
-
import { TypeOrmPagingRepository, IPageParams, IPagedData } from '@onivoro/server-typeorm-postgres';
|
|
171
|
-
import { EntityManager
|
|
187
|
+
import { TypeOrmPagingRepository, IPageParams, IPagedData, getSkip, getPagingKey, removeFalseyKeys } from '@onivoro/server-typeorm-postgres';
|
|
188
|
+
import { EntityManager } from 'typeorm';
|
|
172
189
|
import { User } from './user.entity';
|
|
173
190
|
|
|
174
|
-
// Define your custom params interface
|
|
175
|
-
interface UserPageParams {
|
|
176
|
-
isActive?: boolean;
|
|
177
|
-
search?: string;
|
|
178
|
-
departmentId?: number;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
191
|
@Injectable()
|
|
182
|
-
export class UserPagingRepository extends TypeOrmPagingRepository<User,
|
|
192
|
+
export class UserPagingRepository extends TypeOrmPagingRepository<User, UserSearchParams> {
|
|
183
193
|
constructor(entityManager: EntityManager) {
|
|
184
194
|
super(User, entityManager);
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
//
|
|
188
|
-
async getPage(pageParams: IPageParams, params:
|
|
197
|
+
// Must implement this abstract method
|
|
198
|
+
async getPage(pageParams: IPageParams, params: UserSearchParams): Promise<IPagedData<User>> {
|
|
189
199
|
const { page, limit } = pageParams;
|
|
190
|
-
const skip =
|
|
200
|
+
const skip = getSkip(page, limit);
|
|
191
201
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
isActive: params.isActive
|
|
195
|
-
departmentId: params.departmentId
|
|
202
|
+
const where = removeFalseyKeys({
|
|
203
|
+
departmentId: params.departmentId,
|
|
204
|
+
isActive: params.isActive
|
|
196
205
|
});
|
|
197
206
|
|
|
198
|
-
// Add search conditions if provided
|
|
199
|
-
if (params.search) {
|
|
200
|
-
Object.assign(where, this.buildWhereILike({
|
|
201
|
-
firstName: params.search,
|
|
202
|
-
lastName: params.search,
|
|
203
|
-
email: params.search
|
|
204
|
-
}));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
207
|
const [data, total] = await this.getManyAndCount({
|
|
208
208
|
where,
|
|
209
209
|
skip,
|
|
@@ -221,17 +221,12 @@ export class UserPagingRepository extends TypeOrmPagingRepository<User, UserPage
|
|
|
221
221
|
hasPrev: page > 1
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
|
-
|
|
225
|
-
// You can add additional helper methods
|
|
226
|
-
getCacheKey(pageParams: IPageParams, params: UserPageParams): string {
|
|
227
|
-
return this.getPagingKey(pageParams.page, pageParams.limit) + '_' + JSON.stringify(params);
|
|
228
|
-
}
|
|
229
224
|
}
|
|
230
225
|
```
|
|
231
226
|
|
|
232
|
-
### RedshiftRepository
|
|
227
|
+
### RedshiftRepository
|
|
233
228
|
|
|
234
|
-
Specialized repository for Amazon Redshift with
|
|
229
|
+
Specialized repository for Amazon Redshift with different SQL generation:
|
|
235
230
|
|
|
236
231
|
```typescript
|
|
237
232
|
import { Injectable } from '@nestjs/common';
|
|
@@ -244,485 +239,221 @@ export class AnalyticsRepository extends RedshiftRepository<AnalyticsEvent> {
|
|
|
244
239
|
constructor(entityManager: EntityManager) {
|
|
245
240
|
super(AnalyticsEvent, entityManager);
|
|
246
241
|
}
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
async
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async bulkInsertEvents(events: Partial<AnalyticsEvent>[]): Promise<AnalyticsEvent[]> {
|
|
255
|
-
// Uses optimized bulk insert
|
|
256
|
-
return this.postMany(events);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Performance-optimized methods unique to RedshiftRepository
|
|
260
|
-
async insertWithoutReturn(event: Partial<AnalyticsEvent>): Promise<void> {
|
|
261
|
-
// Inserts without performing retrieval query
|
|
262
|
-
await this.postOneWithoutReturn(event);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async bulkInsertWithoutReturn(events: Partial<AnalyticsEvent>[]): Promise<void> {
|
|
266
|
-
// NOTE: Currently throws NotImplementedException
|
|
267
|
-
// await this.postManyWithoutReturn(events);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Custom analytics queries
|
|
271
|
-
async getEventAnalytics(startDate: Date, endDate: Date) {
|
|
272
|
-
const query = `
|
|
273
|
-
SELECT
|
|
274
|
-
event_type,
|
|
275
|
-
COUNT(*) as event_count,
|
|
276
|
-
COUNT(DISTINCT user_id) as unique_users,
|
|
277
|
-
DATE_TRUNC('day', created_at) as event_date
|
|
278
|
-
FROM ${this.getTableNameExpression()}
|
|
279
|
-
WHERE created_at BETWEEN $1 AND $2
|
|
280
|
-
GROUP BY event_type, event_date
|
|
281
|
-
ORDER BY event_date DESC, event_count DESC
|
|
282
|
-
`;
|
|
242
|
+
|
|
243
|
+
// Optimized methods for Redshift
|
|
244
|
+
async bulkInsertEvents(events: Partial<AnalyticsEvent>[]) {
|
|
245
|
+
// postOne - performs manual insert + select
|
|
246
|
+
const event = await this.postOne({ type: 'click', userId: 123 });
|
|
283
247
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// JSON_PARSE is used automatically for jsonb columns in Redshift
|
|
290
|
-
const query = `
|
|
291
|
-
SELECT * FROM ${this.getTableNameExpression()}
|
|
292
|
-
WHERE JSON_EXTRACT_PATH_TEXT(event_data, '${key}') = $1
|
|
293
|
-
`;
|
|
248
|
+
// postOneWithoutReturn - insert only, no select (better performance)
|
|
249
|
+
await this.postOneWithoutReturn({ type: 'view', userId: 456 });
|
|
250
|
+
|
|
251
|
+
// postMany - bulk insert with retrieval
|
|
252
|
+
const inserted = await this.postMany(events);
|
|
294
253
|
|
|
295
|
-
|
|
254
|
+
// postManyWithoutReturn - NOT IMPLEMENTED (throws error)
|
|
255
|
+
// await this.postManyWithoutReturn(events);
|
|
296
256
|
}
|
|
297
257
|
}
|
|
298
258
|
```
|
|
299
259
|
|
|
260
|
+
Key differences in RedshiftRepository:
|
|
261
|
+
- Uses raw SQL instead of TypeORM query builder for better Redshift compatibility
|
|
262
|
+
- Automatically wraps JSONB values with `JSON_PARSE()`
|
|
263
|
+
- `getMany`, `getOne`, `delete`, `patch` use custom SQL generation
|
|
264
|
+
- `put`, `forTransaction`, `getManyAndCount` throw `NotImplementedException`
|
|
265
|
+
- `softDelete` uses patch with `deletedAt` field
|
|
266
|
+
|
|
300
267
|
## SQL Writer
|
|
301
268
|
|
|
302
269
|
Static utility class for generating PostgreSQL DDL:
|
|
303
270
|
|
|
304
271
|
```typescript
|
|
305
272
|
import { SqlWriter } from '@onivoro/server-typeorm-postgres';
|
|
306
|
-
import { TableColumnOptions } from 'typeorm';
|
|
307
273
|
|
|
308
|
-
//
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
type: 'varchar',
|
|
312
|
-
length: 20,
|
|
313
|
-
isNullable: true
|
|
314
|
-
});
|
|
315
|
-
// Returns: ALTER TABLE "users" ADD "phone_number" varchar(20)
|
|
274
|
+
// All methods return SQL strings
|
|
275
|
+
const sql1 = SqlWriter.addColumn('users', { name: 'phone', type: 'varchar', length: 20 });
|
|
276
|
+
// Returns: ALTER TABLE "users" ADD "phone" varchar(20)
|
|
316
277
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
{ name: '
|
|
320
|
-
{ name: 'name', type: 'varchar', length: 255, isNullable: false },
|
|
321
|
-
{ name: 'price', type: 'decimal', precision: 10, scale: 2 },
|
|
322
|
-
{ name: 'metadata', type: 'jsonb', default: {} },
|
|
323
|
-
{ name: 'created_at', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }
|
|
278
|
+
const sql2 = SqlWriter.addColumns('users', [
|
|
279
|
+
{ name: 'phone', type: 'varchar', length: 20 },
|
|
280
|
+
{ name: 'address', type: 'jsonb', nullable: true }
|
|
324
281
|
]);
|
|
325
282
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// Add multiple columns
|
|
331
|
-
const addColumnsSql = SqlWriter.addColumns('products', [
|
|
332
|
-
{ name: 'category_id', type: 'int', isNullable: true },
|
|
333
|
-
{ name: 'sku', type: 'varchar', length: 50, isUnique: true }
|
|
283
|
+
const sql3 = SqlWriter.createTable('products', [
|
|
284
|
+
{ name: 'id', type: 'serial', isPrimary: true },
|
|
285
|
+
{ name: 'name', type: 'varchar', length: 255 },
|
|
286
|
+
{ name: 'price', type: 'decimal', precision: 10, scale: 2 }
|
|
334
287
|
]);
|
|
335
288
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
// Returns: ALTER TABLE "products" DROP COLUMN old_column
|
|
289
|
+
const sql4 = SqlWriter.dropTable('old_table');
|
|
290
|
+
// Returns: DROP TABLE "old_table";
|
|
339
291
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// Returns: CREATE INDEX IF NOT EXISTS products_name ON "products"(name)
|
|
292
|
+
const sql5 = SqlWriter.dropColumn('users', { name: 'old_column' });
|
|
293
|
+
// Returns: ALTER TABLE "users" DROP COLUMN old_column
|
|
343
294
|
|
|
344
|
-
const
|
|
345
|
-
// Returns: CREATE
|
|
295
|
+
const sql6 = SqlWriter.createIndex('users', 'email', false);
|
|
296
|
+
// Returns: CREATE INDEX IF NOT EXISTS users_email ON "users"(email)
|
|
346
297
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Returns: DROP INDEX IF EXISTS products_name
|
|
298
|
+
const sql7 = SqlWriter.createUniqueIndex('users', 'username');
|
|
299
|
+
// Returns: CREATE UNIQUE INDEX IF NOT EXISTS users_username ON "users"(username)
|
|
350
300
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
name: 'settings',
|
|
354
|
-
type: 'jsonb',
|
|
355
|
-
default: { notifications: true, theme: 'light' }
|
|
356
|
-
};
|
|
357
|
-
const jsonbSql = SqlWriter.addColumn('users', jsonbColumn);
|
|
358
|
-
// Returns: ALTER TABLE "users" ADD "settings" jsonb DEFAULT '{"notifications":true,"theme":"light"}'::jsonb
|
|
359
|
-
|
|
360
|
-
// Boolean and numeric defaults
|
|
361
|
-
const booleanSql = SqlWriter.addColumn('users', {
|
|
362
|
-
name: 'is_verified',
|
|
363
|
-
type: 'boolean',
|
|
364
|
-
default: false
|
|
365
|
-
});
|
|
366
|
-
// Returns: ALTER TABLE "users" ADD "is_verified" boolean DEFAULT FALSE
|
|
301
|
+
const sql8 = SqlWriter.dropIndex('users_email');
|
|
302
|
+
// Returns: DROP INDEX IF EXISTS users_email
|
|
367
303
|
```
|
|
368
304
|
|
|
305
|
+
Special handling for defaults:
|
|
306
|
+
- JSONB values are stringified and cast: `'{"key":"value"}'::jsonb`
|
|
307
|
+
- Booleans become `TRUE`/`FALSE`
|
|
308
|
+
- Other values are quoted appropriately
|
|
309
|
+
|
|
369
310
|
## Migration Base Classes
|
|
370
311
|
|
|
371
|
-
|
|
312
|
+
Simplified migration classes that implement TypeORM's up/down methods:
|
|
372
313
|
|
|
373
314
|
```typescript
|
|
374
|
-
import {
|
|
375
|
-
|
|
315
|
+
import {
|
|
316
|
+
TableMigrationBase,
|
|
317
|
+
ColumnMigrationBase,
|
|
318
|
+
ColumnsMigrationBase,
|
|
319
|
+
IndexMigrationBase,
|
|
320
|
+
DropTableMigrationBase,
|
|
321
|
+
DropColumnMigrationBase
|
|
322
|
+
} from '@onivoro/server-typeorm-postgres';
|
|
376
323
|
|
|
377
|
-
|
|
324
|
+
// Create table
|
|
325
|
+
export class CreateUsersTable1234567890 extends TableMigrationBase {
|
|
378
326
|
constructor() {
|
|
379
327
|
super('users', [
|
|
380
328
|
{ name: 'id', type: 'serial', isPrimary: true },
|
|
381
|
-
{ name: 'email', type: 'varchar', length: 255, isUnique: true
|
|
382
|
-
{ name: 'first_name', type: 'varchar', length: 100, isNullable: false },
|
|
383
|
-
{ name: 'metadata', type: 'jsonb', default: '{}' },
|
|
384
|
-
{ name: 'is_active', type: 'boolean', default: true },
|
|
385
|
-
{ name: 'created_at', type: 'timestamp', default: 'CURRENT_TIMESTAMP' },
|
|
386
|
-
{ name: 'deleted_at', type: 'timestamp', isNullable: true }
|
|
329
|
+
{ name: 'email', type: 'varchar', length: 255, isUnique: true }
|
|
387
330
|
]);
|
|
388
331
|
}
|
|
389
332
|
}
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
### ColumnMigrationBase
|
|
393
|
-
|
|
394
|
-
```typescript
|
|
395
|
-
import { ColumnMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
396
333
|
|
|
397
|
-
|
|
334
|
+
// Add single column
|
|
335
|
+
export class AddUserPhone1234567891 extends ColumnMigrationBase {
|
|
398
336
|
constructor() {
|
|
399
|
-
super('users', {
|
|
400
|
-
name: 'phone_number',
|
|
401
|
-
type: 'varchar',
|
|
402
|
-
length: 20,
|
|
403
|
-
isNullable: true
|
|
404
|
-
});
|
|
337
|
+
super('users', { name: 'phone', type: 'varchar', length: 20, isNullable: true });
|
|
405
338
|
}
|
|
406
339
|
}
|
|
407
|
-
```
|
|
408
340
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
```typescript
|
|
412
|
-
import { ColumnsMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
413
|
-
|
|
414
|
-
export class AddUserContactInfo1234567892 extends ColumnsMigrationBase {
|
|
341
|
+
// Add multiple columns
|
|
342
|
+
export class AddUserDetails1234567892 extends ColumnsMigrationBase {
|
|
415
343
|
constructor() {
|
|
416
344
|
super('users', [
|
|
417
|
-
{ name: '
|
|
418
|
-
{ name: '
|
|
419
|
-
{ name: 'address', type: 'jsonb', isNullable: true }
|
|
345
|
+
{ name: 'phone', type: 'varchar', length: 20 },
|
|
346
|
+
{ name: 'address', type: 'jsonb' }
|
|
420
347
|
]);
|
|
421
348
|
}
|
|
422
349
|
}
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### IndexMigrationBase
|
|
426
350
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
export class CreateUserEmailIndex1234567893 extends IndexMigrationBase {
|
|
351
|
+
// Create index
|
|
352
|
+
export class IndexUserEmail1234567893 extends IndexMigrationBase {
|
|
431
353
|
constructor() {
|
|
432
|
-
super('users', 'email', true); // table, column,
|
|
354
|
+
super('users', 'email', true); // table, column, isUnique
|
|
433
355
|
}
|
|
434
356
|
}
|
|
435
|
-
```
|
|
436
357
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
```typescript
|
|
440
|
-
import { DropTableMigrationBase, DropColumnMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
441
|
-
|
|
442
|
-
export class DropLegacyUsersTable1234567894 extends DropTableMigrationBase {
|
|
358
|
+
// Drop table
|
|
359
|
+
export class DropOldTable1234567894 extends DropTableMigrationBase {
|
|
443
360
|
constructor() {
|
|
444
361
|
super('legacy_users');
|
|
445
362
|
}
|
|
446
363
|
}
|
|
447
364
|
|
|
448
|
-
|
|
365
|
+
// Drop column
|
|
366
|
+
export class DropMiddleName1234567895 extends DropColumnMigrationBase {
|
|
449
367
|
constructor() {
|
|
450
368
|
super('users', { name: 'middle_name' });
|
|
451
369
|
}
|
|
452
370
|
}
|
|
453
371
|
```
|
|
454
372
|
|
|
455
|
-
## Building Repositories from Metadata
|
|
456
|
-
|
|
457
|
-
Both TypeOrmRepository and RedshiftRepository support building instances from metadata:
|
|
458
|
-
|
|
459
|
-
```typescript
|
|
460
|
-
import { TypeOrmRepository, RedshiftRepository } from '@onivoro/server-typeorm-postgres';
|
|
461
|
-
import { DataSource } from 'typeorm';
|
|
462
|
-
|
|
463
|
-
// Define your entity type
|
|
464
|
-
interface UserEvent {
|
|
465
|
-
id: number;
|
|
466
|
-
userId: number;
|
|
467
|
-
eventType: string;
|
|
468
|
-
eventData: any;
|
|
469
|
-
createdAt: Date;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Build TypeORM repository from metadata
|
|
473
|
-
const userEventRepo = TypeOrmRepository.buildFromMetadata<UserEvent>(dataSource, {
|
|
474
|
-
schema: 'public',
|
|
475
|
-
table: 'user_events',
|
|
476
|
-
columns: {
|
|
477
|
-
id: {
|
|
478
|
-
databasePath: 'id',
|
|
479
|
-
type: 'int',
|
|
480
|
-
propertyPath: 'id',
|
|
481
|
-
isPrimary: true,
|
|
482
|
-
default: undefined
|
|
483
|
-
},
|
|
484
|
-
userId: {
|
|
485
|
-
databasePath: 'user_id',
|
|
486
|
-
type: 'int',
|
|
487
|
-
propertyPath: 'userId',
|
|
488
|
-
isPrimary: false,
|
|
489
|
-
default: undefined
|
|
490
|
-
},
|
|
491
|
-
eventType: {
|
|
492
|
-
databasePath: 'event_type',
|
|
493
|
-
type: 'varchar',
|
|
494
|
-
propertyPath: 'eventType',
|
|
495
|
-
isPrimary: false,
|
|
496
|
-
default: undefined
|
|
497
|
-
},
|
|
498
|
-
eventData: {
|
|
499
|
-
databasePath: 'event_data',
|
|
500
|
-
type: 'jsonb',
|
|
501
|
-
propertyPath: 'eventData',
|
|
502
|
-
isPrimary: false,
|
|
503
|
-
default: {}
|
|
504
|
-
},
|
|
505
|
-
createdAt: {
|
|
506
|
-
databasePath: 'created_at',
|
|
507
|
-
type: 'timestamp',
|
|
508
|
-
propertyPath: 'createdAt',
|
|
509
|
-
isPrimary: false,
|
|
510
|
-
default: 'CURRENT_TIMESTAMP'
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
// Build Redshift repository from metadata
|
|
516
|
-
const analyticsRepo = RedshiftRepository.buildFromMetadata<UserEvent>(redshiftDataSource, {
|
|
517
|
-
schema: 'analytics',
|
|
518
|
-
table: 'user_events',
|
|
519
|
-
columns: {
|
|
520
|
-
// Same column definitions as above
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// Use the repositories
|
|
525
|
-
const events = await userEventRepo.getMany({ where: { userId: 123 } });
|
|
526
|
-
const recentEvent = await userEventRepo.getOne({ where: { id: 456 } });
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
## Data Source Configuration
|
|
530
|
-
|
|
531
|
-
```typescript
|
|
532
|
-
import { dataSourceFactory, dataSourceConfigFactory } from '@onivoro/server-typeorm-postgres';
|
|
533
|
-
import { User, Product, Order } from './entities';
|
|
534
|
-
|
|
535
|
-
// Using data source factory
|
|
536
|
-
const dataSource = dataSourceFactory('postgres-main', {
|
|
537
|
-
host: 'localhost',
|
|
538
|
-
port: 5432,
|
|
539
|
-
username: 'postgres',
|
|
540
|
-
password: 'password',
|
|
541
|
-
database: 'myapp'
|
|
542
|
-
}, [User, Product, Order]);
|
|
543
|
-
|
|
544
|
-
// Using config factory for more control
|
|
545
|
-
const config = dataSourceConfigFactory('postgres-main', {
|
|
546
|
-
host: process.env.DB_HOST,
|
|
547
|
-
port: parseInt(process.env.DB_PORT),
|
|
548
|
-
username: process.env.DB_USERNAME,
|
|
549
|
-
password: process.env.DB_PASSWORD,
|
|
550
|
-
database: process.env.DB_DATABASE,
|
|
551
|
-
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
|
552
|
-
}, [User, Product, Order]);
|
|
553
|
-
|
|
554
|
-
const dataSource = new DataSource(config);
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
## Type Definitions
|
|
558
|
-
|
|
559
|
-
### Core Types
|
|
560
|
-
|
|
561
|
-
```typescript
|
|
562
|
-
// Table metadata
|
|
563
|
-
interface TTableMeta {
|
|
564
|
-
databasePath: string;
|
|
565
|
-
type: string;
|
|
566
|
-
propertyPath: string;
|
|
567
|
-
isPrimary: boolean;
|
|
568
|
-
default?: any;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Page parameters
|
|
572
|
-
interface IPageParams {
|
|
573
|
-
page: number;
|
|
574
|
-
limit: number;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Paged data result
|
|
578
|
-
interface IPagedData<T> {
|
|
579
|
-
data: T[];
|
|
580
|
-
total: number;
|
|
581
|
-
page: number;
|
|
582
|
-
limit: number;
|
|
583
|
-
totalPages: number;
|
|
584
|
-
hasNext: boolean;
|
|
585
|
-
hasPrev: boolean;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Data source options
|
|
589
|
-
interface IDataSourceOptions {
|
|
590
|
-
host: string;
|
|
591
|
-
port: number;
|
|
592
|
-
username: string;
|
|
593
|
-
password: string;
|
|
594
|
-
database: string;
|
|
595
|
-
ssl?: any;
|
|
596
|
-
extra?: any;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Entity provider interface
|
|
600
|
-
interface IEntityProvider<TEntity, TFindOneOptions, TFindManyOptions, TFindOptionsWhere, TUpdateData> {
|
|
601
|
-
getOne(options: TFindOneOptions): Promise<TEntity>;
|
|
602
|
-
getMany(options: TFindManyOptions): Promise<TEntity[]>;
|
|
603
|
-
getManyAndCount(options: TFindManyOptions): Promise<[TEntity[], number]>;
|
|
604
|
-
postOne(body: Partial<TEntity>): Promise<TEntity>;
|
|
605
|
-
postMany(body: Partial<TEntity>[]): Promise<TEntity[]>;
|
|
606
|
-
delete(options: TFindOptionsWhere): Promise<void>;
|
|
607
|
-
softDelete(options: TFindOptionsWhere): Promise<void>;
|
|
608
|
-
put(options: TFindOptionsWhere, body: TUpdateData): Promise<void>;
|
|
609
|
-
patch(options: TFindOptionsWhere, body: TUpdateData): Promise<void>;
|
|
610
|
-
}
|
|
611
|
-
```
|
|
612
|
-
|
|
613
373
|
## Utility Functions
|
|
614
374
|
|
|
615
375
|
```typescript
|
|
616
376
|
import {
|
|
617
|
-
getSkip,
|
|
618
|
-
getPagingKey,
|
|
377
|
+
getSkip,
|
|
378
|
+
getPagingKey,
|
|
619
379
|
removeFalseyKeys,
|
|
620
380
|
generateDateQuery,
|
|
621
|
-
getApiTypeFromColumn
|
|
381
|
+
getApiTypeFromColumn,
|
|
382
|
+
dataSourceFactory,
|
|
383
|
+
dataSourceConfigFactory
|
|
622
384
|
} from '@onivoro/server-typeorm-postgres';
|
|
623
385
|
|
|
624
|
-
//
|
|
386
|
+
// Pagination helpers
|
|
625
387
|
const skip = getSkip(2, 20); // page 2, limit 20 = skip 20
|
|
388
|
+
const cacheKey = getPagingKey(2, 20); // "page_2_limit_20"
|
|
626
389
|
|
|
627
|
-
//
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
// Remove falsey values from object
|
|
631
|
-
const cleanedFilters = removeFalseyKeys({
|
|
390
|
+
// Remove null/undefined/empty string values
|
|
391
|
+
const clean = removeFalseyKeys({
|
|
632
392
|
name: 'John',
|
|
633
|
-
age:
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
dept: null // Removed
|
|
393
|
+
age: null, // removed
|
|
394
|
+
email: '', // removed
|
|
395
|
+
active: false // kept
|
|
637
396
|
});
|
|
638
397
|
|
|
639
|
-
//
|
|
640
|
-
const
|
|
398
|
+
// Date range query builder
|
|
399
|
+
const dateFilter = generateDateQuery('created_at', {
|
|
641
400
|
startDate: new Date('2024-01-01'),
|
|
642
401
|
endDate: new Date('2024-12-31')
|
|
643
402
|
});
|
|
403
|
+
// Returns TypeORM Between operator
|
|
644
404
|
|
|
645
|
-
//
|
|
646
|
-
const apiType = getApiTypeFromColumn(
|
|
647
|
-
|
|
405
|
+
// Column type to API type mapping
|
|
406
|
+
const apiType = getApiTypeFromColumn('varchar'); // 'string'
|
|
407
|
+
const apiType2 = getApiTypeFromColumn('int'); // 'number'
|
|
408
|
+
const apiType3 = getApiTypeFromColumn('jsonb'); // 'object'
|
|
648
409
|
|
|
649
|
-
|
|
410
|
+
// Create data source
|
|
411
|
+
const ds = dataSourceFactory('main', {
|
|
412
|
+
host: 'localhost',
|
|
413
|
+
port: 5432,
|
|
414
|
+
username: 'user',
|
|
415
|
+
password: 'pass',
|
|
416
|
+
database: 'db'
|
|
417
|
+
}, [User, Product]);
|
|
418
|
+
```
|
|
650
419
|
|
|
651
|
-
|
|
652
|
-
2. **Redshift Operations**: Use RedshiftRepository for analytics workloads with specific optimizations
|
|
653
|
-
3. **Pagination**: Implement TypeOrmPagingRepository for consistent pagination across your app
|
|
654
|
-
4. **Migrations**: Use migration base classes for consistent schema management
|
|
655
|
-
5. **SQL Generation**: Use SqlWriter for complex DDL operations
|
|
656
|
-
6. **Transactions**: Use `forTransaction()` to create transaction-scoped repositories
|
|
657
|
-
7. **Performance**: For Redshift bulk inserts, use `postOneWithoutReturn()` when you don't need the inserted record back
|
|
658
|
-
8. **Type Safety**: Leverage the strongly-typed column metadata for compile-time safety
|
|
420
|
+
## Building Repositories from Metadata
|
|
659
421
|
|
|
660
|
-
|
|
422
|
+
For dynamic entity handling without TypeORM decorators:
|
|
661
423
|
|
|
662
424
|
```typescript
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
425
|
+
const metadata = {
|
|
426
|
+
schema: 'public',
|
|
427
|
+
table: 'events',
|
|
428
|
+
columns: {
|
|
429
|
+
id: { databasePath: 'id', type: 'int', propertyPath: 'id', isPrimary: true, default: undefined },
|
|
430
|
+
type: { databasePath: 'event_type', type: 'varchar', propertyPath: 'type', isPrimary: false, default: undefined },
|
|
431
|
+
data: { databasePath: 'event_data', type: 'jsonb', propertyPath: 'data', isPrimary: false, default: {} }
|
|
432
|
+
}
|
|
433
|
+
};
|
|
668
434
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
let entityManager: EntityManager;
|
|
672
|
-
|
|
673
|
-
beforeEach(async () => {
|
|
674
|
-
const module = await Test.createTestingModule({
|
|
675
|
-
imports: [
|
|
676
|
-
TypeOrmModule.forRoot({
|
|
677
|
-
type: 'postgres',
|
|
678
|
-
host: 'localhost',
|
|
679
|
-
port: 5432,
|
|
680
|
-
username: 'test',
|
|
681
|
-
password: 'test',
|
|
682
|
-
database: 'test_db',
|
|
683
|
-
entities: [User],
|
|
684
|
-
synchronize: true,
|
|
685
|
-
}),
|
|
686
|
-
TypeOrmModule.forFeature([User])
|
|
687
|
-
],
|
|
688
|
-
providers: [UserRepository],
|
|
689
|
-
}).compile();
|
|
690
|
-
|
|
691
|
-
entityManager = module.get<EntityManager>(EntityManager);
|
|
692
|
-
repository = new UserRepository(entityManager);
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
it('should create and retrieve user', async () => {
|
|
696
|
-
const userData = {
|
|
697
|
-
email: 'test@example.com',
|
|
698
|
-
firstName: 'John',
|
|
699
|
-
isActive: true
|
|
700
|
-
};
|
|
435
|
+
// Build TypeORM repository
|
|
436
|
+
const eventRepo = TypeOrmRepository.buildFromMetadata(dataSource, metadata);
|
|
701
437
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
expect(user.email).toBe('test@example.com');
|
|
705
|
-
|
|
706
|
-
const foundUser = await repository.getOne({ where: { id: user.id } });
|
|
707
|
-
expect(foundUser).toEqual(user);
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
it('should handle transactions', async () => {
|
|
711
|
-
await entityManager.transaction(async (transactionalEntityManager) => {
|
|
712
|
-
const txRepository = repository.forTransaction(transactionalEntityManager);
|
|
713
|
-
|
|
714
|
-
await txRepository.postOne({
|
|
715
|
-
email: 'tx@example.com',
|
|
716
|
-
firstName: 'Transaction',
|
|
717
|
-
isActive: true
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
// Transaction will be rolled back after test
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
});
|
|
438
|
+
// Build Redshift repository
|
|
439
|
+
const analyticsRepo = RedshiftRepository.buildFromMetadata(redshiftDataSource, metadata);
|
|
724
440
|
```
|
|
725
441
|
|
|
442
|
+
## Important Implementation Details
|
|
443
|
+
|
|
444
|
+
1. **Data Source Caching**: The module caches data sources by name to prevent multiple connections
|
|
445
|
+
2. **Snake Case**: All database columns use snake_case via `SnakeNamingStrategy`
|
|
446
|
+
3. **Column Decorators**: The custom decorators are thin wrappers - use TypeORM decorators for full control
|
|
447
|
+
4. **Repository Methods**:
|
|
448
|
+
- `patch` uses TypeORM's `update()` - doesn't trigger entity hooks
|
|
449
|
+
- `put` uses TypeORM's `save()` - triggers entity hooks
|
|
450
|
+
- `getOne` throws error if multiple results found
|
|
451
|
+
5. **Redshift Limitations**:
|
|
452
|
+
- No transaction support
|
|
453
|
+
- No `getManyAndCount`
|
|
454
|
+
- `postManyWithoutReturn` not implemented
|
|
455
|
+
- Automatic `JSON_PARSE()` wrapping for JSONB columns
|
|
456
|
+
|
|
726
457
|
## License
|
|
727
458
|
|
|
728
|
-
|
|
459
|
+
MIT
|