@onivoro/server-typeorm-postgres 24.0.0 → 24.0.1
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 +509 -703
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @onivoro/server-typeorm-postgres
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A TypeORM PostgreSQL integration library providing repository patterns, SQL generation utilities, migration base classes, and PostgreSQL/Redshift-specific optimizations for enterprise-scale applications.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -10,43 +10,30 @@ npm install @onivoro/server-typeorm-postgres
|
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- **TypeORM
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **Data Source Factory**: Flexible data source configuration and creation
|
|
20
|
-
- **Pagination Support**: Built-in pagination utilities and interfaces
|
|
13
|
+
- **TypeORM Repository Pattern**: Enhanced repository with PostgreSQL-specific features
|
|
14
|
+
- **Redshift Repository**: Specialized repository for Amazon Redshift operations
|
|
15
|
+
- **SQL Writer**: Static utility class for PostgreSQL DDL generation
|
|
16
|
+
- **Migration Base Classes**: Simplified migration classes for common operations
|
|
17
|
+
- **Custom Decorators**: Table and column decorators for entity definitions
|
|
18
|
+
- **Pagination Support**: Abstract paging repository for custom implementations
|
|
21
19
|
- **Type Safety**: Full TypeScript support with comprehensive type definitions
|
|
22
|
-
- **PostgreSQL Optimizations**: PostgreSQL-specific optimizations and best practices
|
|
23
20
|
|
|
24
21
|
## Quick Start
|
|
25
22
|
|
|
26
|
-
### Import
|
|
23
|
+
### Module Import
|
|
27
24
|
|
|
28
25
|
```typescript
|
|
29
26
|
import { ServerTypeormPostgresModule } from '@onivoro/server-typeorm-postgres';
|
|
30
27
|
|
|
31
28
|
@Module({
|
|
32
29
|
imports: [
|
|
33
|
-
ServerTypeormPostgresModule
|
|
34
|
-
host: 'localhost',
|
|
35
|
-
port: 5432,
|
|
36
|
-
username: 'postgres',
|
|
37
|
-
password: 'password',
|
|
38
|
-
database: 'myapp',
|
|
39
|
-
entities: [User, Product, Order],
|
|
40
|
-
synchronize: false,
|
|
41
|
-
logging: true,
|
|
42
|
-
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
|
43
|
-
})
|
|
30
|
+
ServerTypeormPostgresModule
|
|
44
31
|
],
|
|
45
32
|
})
|
|
46
33
|
export class AppModule {}
|
|
47
34
|
```
|
|
48
35
|
|
|
49
|
-
###
|
|
36
|
+
### Entity Definition with Custom Decorators
|
|
50
37
|
|
|
51
38
|
```typescript
|
|
52
39
|
import {
|
|
@@ -55,10 +42,8 @@ import {
|
|
|
55
42
|
TableColumn,
|
|
56
43
|
NullableTableColumn
|
|
57
44
|
} from '@onivoro/server-typeorm-postgres';
|
|
58
|
-
import { Entity } from 'typeorm';
|
|
59
45
|
|
|
60
|
-
@
|
|
61
|
-
@Table('users')
|
|
46
|
+
@Table({ name: 'users' })
|
|
62
47
|
export class User {
|
|
63
48
|
@PrimaryTableColumn()
|
|
64
49
|
id: number;
|
|
@@ -69,850 +54,671 @@ export class User {
|
|
|
69
54
|
@TableColumn({ type: 'varchar', length: 100 })
|
|
70
55
|
firstName: string;
|
|
71
56
|
|
|
72
|
-
@TableColumn({ type: 'varchar', length: 100 })
|
|
73
|
-
lastName: string;
|
|
74
|
-
|
|
75
57
|
@NullableTableColumn({ type: 'timestamp' })
|
|
76
58
|
lastLoginAt?: Date;
|
|
77
59
|
|
|
78
60
|
@TableColumn({ type: 'boolean', default: true })
|
|
79
61
|
isActive: boolean;
|
|
80
62
|
|
|
81
|
-
@TableColumn({ type: 'jsonb' })
|
|
63
|
+
@TableColumn({ type: 'jsonb', default: '{}' })
|
|
82
64
|
metadata: Record<string, any>;
|
|
83
65
|
|
|
84
66
|
@TableColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
|
85
67
|
createdAt: Date;
|
|
86
68
|
|
|
87
|
-
@TableColumn({
|
|
88
|
-
type: 'timestamp',
|
|
89
|
-
default: () => 'CURRENT_TIMESTAMP',
|
|
90
|
-
onUpdate: 'CURRENT_TIMESTAMP'
|
|
91
|
-
})
|
|
92
|
-
updatedAt: Date;
|
|
93
|
-
|
|
94
69
|
@NullableTableColumn({ type: 'timestamp' })
|
|
95
70
|
deletedAt?: Date;
|
|
96
71
|
}
|
|
97
72
|
```
|
|
98
73
|
|
|
99
|
-
|
|
74
|
+
## Repository Classes
|
|
75
|
+
|
|
76
|
+
### TypeOrmRepository
|
|
77
|
+
|
|
78
|
+
Enhanced repository with PostgreSQL-specific methods:
|
|
100
79
|
|
|
101
80
|
```typescript
|
|
102
81
|
import { Injectable } from '@nestjs/common';
|
|
103
|
-
import { TypeOrmRepository
|
|
82
|
+
import { TypeOrmRepository } from '@onivoro/server-typeorm-postgres';
|
|
83
|
+
import { EntityManager } from 'typeorm';
|
|
104
84
|
import { User } from './user.entity';
|
|
105
85
|
|
|
106
86
|
@Injectable()
|
|
107
|
-
export class UserRepository extends
|
|
108
|
-
constructor() {
|
|
109
|
-
super(User);
|
|
87
|
+
export class UserRepository extends TypeOrmRepository<User> {
|
|
88
|
+
constructor(entityManager: EntityManager) {
|
|
89
|
+
super(User, entityManager);
|
|
110
90
|
}
|
|
111
91
|
|
|
112
|
-
|
|
113
|
-
|
|
92
|
+
// Core methods available:
|
|
93
|
+
async findByEmail(email: string): Promise<User> {
|
|
94
|
+
return this.getOne({ where: { email } });
|
|
114
95
|
}
|
|
115
96
|
|
|
116
97
|
async findActiveUsers(): Promise<User[]> {
|
|
117
|
-
return this.
|
|
118
|
-
where: { isActive: true
|
|
98
|
+
return this.getMany({
|
|
99
|
+
where: { isActive: true }
|
|
119
100
|
});
|
|
120
101
|
}
|
|
121
102
|
|
|
122
|
-
async
|
|
123
|
-
return this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})
|
|
127
|
-
.getMany();
|
|
103
|
+
async findUsersWithCount(): Promise<[User[], number]> {
|
|
104
|
+
return this.getManyAndCount({
|
|
105
|
+
where: { isActive: true }
|
|
106
|
+
});
|
|
128
107
|
}
|
|
129
108
|
|
|
130
|
-
async
|
|
131
|
-
|
|
109
|
+
async createUser(userData: Partial<User>): Promise<User> {
|
|
110
|
+
return this.postOne(userData);
|
|
132
111
|
}
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Configuration
|
|
137
|
-
|
|
138
|
-
### Data Source Configuration
|
|
139
|
-
|
|
140
|
-
```typescript
|
|
141
|
-
import { dataSourceConfigFactory } from '@onivoro/server-typeorm-postgres';
|
|
142
112
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
port: parseInt(process.env.DB_PORT),
|
|
146
|
-
username: process.env.DB_USERNAME,
|
|
147
|
-
password: process.env.DB_PASSWORD,
|
|
148
|
-
database: process.env.DB_DATABASE,
|
|
149
|
-
entities: [User, Product, Order],
|
|
150
|
-
migrations: ['src/migrations/*.ts'],
|
|
151
|
-
synchronize: false,
|
|
152
|
-
logging: process.env.NODE_ENV === 'development',
|
|
153
|
-
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
|
154
|
-
extra: {
|
|
155
|
-
max: 20, // Connection pool size
|
|
156
|
-
idleTimeoutMillis: 30000,
|
|
157
|
-
connectionTimeoutMillis: 2000,
|
|
113
|
+
async createUsers(usersData: Partial<User>[]): Promise<User[]> {
|
|
114
|
+
return this.postMany(usersData);
|
|
158
115
|
}
|
|
159
|
-
});
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
### Dynamic Module Configuration
|
|
163
|
-
|
|
164
|
-
```typescript
|
|
165
|
-
import { Module } from '@nestjs/common';
|
|
166
|
-
import { ServerTypeormPostgresModule } from '@onivoro/server-typeorm-postgres';
|
|
167
|
-
import { ConfigService } from '@nestjs/config';
|
|
168
|
-
|
|
169
|
-
@Module({
|
|
170
|
-
imports: [
|
|
171
|
-
ServerTypeormPostgresModule.forRootAsync({
|
|
172
|
-
useFactory: (configService: ConfigService) => ({
|
|
173
|
-
host: configService.get('DATABASE_HOST'),
|
|
174
|
-
port: configService.get('DATABASE_PORT'),
|
|
175
|
-
username: configService.get('DATABASE_USERNAME'),
|
|
176
|
-
password: configService.get('DATABASE_PASSWORD'),
|
|
177
|
-
database: configService.get('DATABASE_NAME'),
|
|
178
|
-
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
|
179
|
-
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
|
180
|
-
synchronize: configService.get('NODE_ENV') === 'development',
|
|
181
|
-
logging: configService.get('DATABASE_LOGGING') === 'true',
|
|
182
|
-
ssl: configService.get('NODE_ENV') === 'production' ? {
|
|
183
|
-
rejectUnauthorized: false
|
|
184
|
-
} : false
|
|
185
|
-
}),
|
|
186
|
-
inject: [ConfigService]
|
|
187
|
-
})
|
|
188
|
-
],
|
|
189
|
-
})
|
|
190
|
-
export class DatabaseModule {}
|
|
191
|
-
```
|
|
192
116
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
117
|
+
async updateUser(id: number, updates: Partial<User>): Promise<void> {
|
|
118
|
+
// patch() uses TypeORM's update() method
|
|
119
|
+
await this.patch({ id }, updates);
|
|
120
|
+
}
|
|
196
121
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
IndexMigrationBase,
|
|
202
|
-
DropTableMigrationBase
|
|
203
|
-
} from '@onivoro/server-typeorm-postgres';
|
|
204
|
-
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
122
|
+
async replaceUser(id: number, userData: Partial<User>): Promise<void> {
|
|
123
|
+
// put() uses TypeORM's save() method
|
|
124
|
+
await this.put({ id }, userData);
|
|
125
|
+
}
|
|
205
126
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.createColumn('id', 'SERIAL', { isPrimary: true }),
|
|
210
|
-
this.createColumn('email', 'VARCHAR(255)', { isUnique: true, isNullable: false }),
|
|
211
|
-
this.createColumn('first_name', 'VARCHAR(100)', { isNullable: false }),
|
|
212
|
-
this.createColumn('last_name', 'VARCHAR(100)', { isNullable: false }),
|
|
213
|
-
this.createColumn('metadata', 'JSONB', { default: "'{}'" }),
|
|
214
|
-
this.createColumn('is_active', 'BOOLEAN', { default: true }),
|
|
215
|
-
this.createColumn('created_at', 'TIMESTAMP', { default: 'CURRENT_TIMESTAMP' }),
|
|
216
|
-
this.createColumn('updated_at', 'TIMESTAMP', { default: 'CURRENT_TIMESTAMP' }),
|
|
217
|
-
this.createColumn('deleted_at', 'TIMESTAMP', { isNullable: true })
|
|
218
|
-
]);
|
|
127
|
+
async deleteUser(id: number): Promise<void> {
|
|
128
|
+
await this.delete({ id });
|
|
129
|
+
}
|
|
219
130
|
|
|
220
|
-
|
|
221
|
-
await this.
|
|
222
|
-
await this.createIndex(queryRunner, 'users', ['is_active']);
|
|
223
|
-
await this.createIndex(queryRunner, 'users', ['created_at']);
|
|
131
|
+
async softDeleteUser(id: number): Promise<void> {
|
|
132
|
+
await this.softDelete({ id });
|
|
224
133
|
}
|
|
225
134
|
|
|
226
|
-
|
|
227
|
-
|
|
135
|
+
// Transaction support
|
|
136
|
+
async createUserInTransaction(userData: Partial<User>, entityManager: EntityManager): Promise<User> {
|
|
137
|
+
const txRepository = this.forTransaction(entityManager);
|
|
138
|
+
return txRepository.postOne(userData);
|
|
228
139
|
}
|
|
229
|
-
}
|
|
230
140
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
141
|
+
// Custom queries with mapping
|
|
142
|
+
async findUsersByMetadata(key: string, value: any): Promise<User[]> {
|
|
143
|
+
const query = `
|
|
144
|
+
SELECT * FROM ${this.getTableNameExpression()}
|
|
145
|
+
WHERE metadata->>'${key}' = $1
|
|
146
|
+
AND deleted_at IS NULL
|
|
147
|
+
`;
|
|
148
|
+
return this.queryAndMap(query, [value]);
|
|
236
149
|
}
|
|
237
150
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
151
|
+
// Using ILike for case-insensitive search
|
|
152
|
+
async searchUsers(searchTerm: string): Promise<User[]> {
|
|
153
|
+
const filters = this.buildWhereILike({
|
|
154
|
+
firstName: searchTerm,
|
|
155
|
+
lastName: searchTerm,
|
|
156
|
+
email: searchTerm
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return this.getMany({ where: filters });
|
|
242
160
|
}
|
|
243
161
|
}
|
|
244
162
|
```
|
|
245
163
|
|
|
246
|
-
###
|
|
164
|
+
### TypeOrmPagingRepository
|
|
165
|
+
|
|
166
|
+
Abstract base class for implementing pagination:
|
|
247
167
|
|
|
248
168
|
```typescript
|
|
249
169
|
import { Injectable } from '@nestjs/common';
|
|
250
|
-
import { TypeOrmPagingRepository,
|
|
170
|
+
import { TypeOrmPagingRepository, IPageParams, IPagedData } from '@onivoro/server-typeorm-postgres';
|
|
171
|
+
import { EntityManager, FindManyOptions } from 'typeorm';
|
|
251
172
|
import { User } from './user.entity';
|
|
252
|
-
import { FindOptionsWhere, ILike, Raw, Between } from 'typeorm';
|
|
253
173
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
174
|
+
// Define your custom params interface
|
|
175
|
+
interface UserPageParams {
|
|
176
|
+
isActive?: boolean;
|
|
177
|
+
search?: string;
|
|
178
|
+
departmentId?: number;
|
|
179
|
+
}
|
|
259
180
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// PostgreSQL full-text search
|
|
265
|
-
return this.findWithPaging(
|
|
266
|
-
{
|
|
267
|
-
where: Raw(alias => `to_tsvector('english', ${alias}.first_name || ' ' || ${alias}.last_name || ' ' || ${alias}.email) @@ plainto_tsquery('english', :searchTerm)`, { searchTerm }),
|
|
268
|
-
order: { createdAt: 'DESC' }
|
|
269
|
-
},
|
|
270
|
-
pageParams
|
|
271
|
-
);
|
|
181
|
+
@Injectable()
|
|
182
|
+
export class UserPagingRepository extends TypeOrmPagingRepository<User, UserPageParams> {
|
|
183
|
+
constructor(entityManager: EntityManager) {
|
|
184
|
+
super(User, entityManager);
|
|
272
185
|
}
|
|
273
186
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
): Promise<PagedData<User>> {
|
|
279
|
-
return this.findWithPaging(
|
|
280
|
-
{
|
|
281
|
-
where: Raw(alias => `${alias}.metadata #>> :path = :value`, {
|
|
282
|
-
path: `{${jsonPath}}`,
|
|
283
|
-
value: String(value)
|
|
284
|
-
})
|
|
285
|
-
},
|
|
286
|
-
pageParams
|
|
287
|
-
);
|
|
288
|
-
}
|
|
187
|
+
// You must implement the abstract getPage method
|
|
188
|
+
async getPage(pageParams: IPageParams, params: UserPageParams): Promise<IPagedData<User>> {
|
|
189
|
+
const { page, limit } = pageParams;
|
|
190
|
+
const skip = this.getSkip(page, limit);
|
|
289
191
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.where(`user.metadata->:key @> :value`, {
|
|
296
|
-
key: metadataKey,
|
|
297
|
-
value: JSON.stringify([containsValue])
|
|
298
|
-
})
|
|
299
|
-
.getMany();
|
|
300
|
-
}
|
|
192
|
+
// Build where conditions
|
|
193
|
+
const where = this.removeFalseyKeys({
|
|
194
|
+
isActive: params.isActive,
|
|
195
|
+
departmentId: params.departmentId
|
|
196
|
+
});
|
|
301
197
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
createdAt: Between(startDate, endDate),
|
|
311
|
-
deletedAt: null
|
|
312
|
-
},
|
|
313
|
-
order: { createdAt: 'DESC' }
|
|
314
|
-
},
|
|
315
|
-
pageParams
|
|
316
|
-
);
|
|
317
|
-
}
|
|
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
|
+
}
|
|
318
206
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}> {
|
|
326
|
-
const result = await this.createQueryBuilder('user')
|
|
327
|
-
.select([
|
|
328
|
-
'COUNT(*) as total',
|
|
329
|
-
'COUNT(CASE WHEN user.isActive = true THEN 1 END) as active',
|
|
330
|
-
'COUNT(CASE WHEN user.isActive = false THEN 1 END) as inactive',
|
|
331
|
-
'AVG(jsonb_array_length(user.metadata)) as avgMetadataSize',
|
|
332
|
-
`COUNT(CASE WHEN user.createdAt >= :weekAgo THEN 1 END) as recentRegistrations`
|
|
333
|
-
])
|
|
334
|
-
.where('user.deletedAt IS NULL')
|
|
335
|
-
.setParameter('weekAgo', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
|
336
|
-
.getRawOne();
|
|
207
|
+
const [data, total] = await this.getManyAndCount({
|
|
208
|
+
where,
|
|
209
|
+
skip,
|
|
210
|
+
take: limit,
|
|
211
|
+
order: { createdAt: 'DESC' }
|
|
212
|
+
});
|
|
337
213
|
|
|
338
214
|
return {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
215
|
+
data,
|
|
216
|
+
total,
|
|
217
|
+
page,
|
|
218
|
+
limit,
|
|
219
|
+
totalPages: Math.ceil(total / limit),
|
|
220
|
+
hasNext: page < Math.ceil(total / limit),
|
|
221
|
+
hasPrev: page > 1
|
|
344
222
|
};
|
|
345
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
|
+
}
|
|
346
229
|
}
|
|
347
230
|
```
|
|
348
231
|
|
|
349
|
-
###
|
|
232
|
+
### RedshiftRepository
|
|
233
|
+
|
|
234
|
+
Specialized repository for Amazon Redshift with custom SQL building:
|
|
350
235
|
|
|
351
236
|
```typescript
|
|
352
237
|
import { Injectable } from '@nestjs/common';
|
|
353
238
|
import { RedshiftRepository } from '@onivoro/server-typeorm-postgres';
|
|
239
|
+
import { EntityManager } from 'typeorm';
|
|
240
|
+
import { AnalyticsEvent } from './analytics-event.entity';
|
|
354
241
|
|
|
355
242
|
@Injectable()
|
|
356
|
-
export class AnalyticsRepository extends RedshiftRepository {
|
|
357
|
-
constructor() {
|
|
358
|
-
super();
|
|
243
|
+
export class AnalyticsRepository extends RedshiftRepository<AnalyticsEvent> {
|
|
244
|
+
constructor(entityManager: EntityManager) {
|
|
245
|
+
super(AnalyticsEvent, entityManager);
|
|
359
246
|
}
|
|
360
247
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
u.email,
|
|
366
|
-
COUNT(a.id) as activity_count,
|
|
367
|
-
MAX(a.created_at) as last_activity,
|
|
368
|
-
AVG(a.duration) as avg_duration
|
|
369
|
-
FROM users u
|
|
370
|
-
LEFT JOIN user_activities a ON u.id = a.user_id
|
|
371
|
-
WHERE a.created_at BETWEEN $1 AND $2
|
|
372
|
-
GROUP BY u.id, u.email
|
|
373
|
-
ORDER BY activity_count DESC
|
|
374
|
-
LIMIT 100
|
|
375
|
-
`, [startDate, endDate]);
|
|
248
|
+
// RedshiftRepository overrides several methods for Redshift compatibility
|
|
249
|
+
async createAnalyticsEvent(event: Partial<AnalyticsEvent>): Promise<AnalyticsEvent> {
|
|
250
|
+
// Uses custom SQL building and retrieval
|
|
251
|
+
return this.postOne(event);
|
|
376
252
|
}
|
|
377
253
|
|
|
378
|
-
async
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
DATE_TRUNC('month', created_at) as month,
|
|
382
|
-
COUNT(*) as new_users,
|
|
383
|
-
SUM(COUNT(*)) OVER (ORDER BY DATE_TRUNC('month', created_at)) as cumulative_users
|
|
384
|
-
FROM users
|
|
385
|
-
WHERE deleted_at IS NULL
|
|
386
|
-
GROUP BY DATE_TRUNC('month', created_at)
|
|
387
|
-
ORDER BY month
|
|
388
|
-
`);
|
|
254
|
+
async bulkInsertEvents(events: Partial<AnalyticsEvent>[]): Promise<AnalyticsEvent[]> {
|
|
255
|
+
// Uses optimized bulk insert
|
|
256
|
+
return this.postMany(events);
|
|
389
257
|
}
|
|
390
258
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 = `
|
|
404
273
|
SELECT
|
|
405
|
-
|
|
406
|
-
COUNT(*) as
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
GROUP BY
|
|
412
|
-
ORDER BY
|
|
413
|
-
|
|
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
|
+
`;
|
|
283
|
+
|
|
284
|
+
return this.query(query, [startDate, endDate]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Redshift handles JSONB differently
|
|
288
|
+
async findEventsByJsonData(key: string, value: any): Promise<AnalyticsEvent[]> {
|
|
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
|
+
`;
|
|
294
|
+
|
|
295
|
+
return this.queryAndMap(query, [value]);
|
|
414
296
|
}
|
|
415
297
|
}
|
|
416
298
|
```
|
|
417
299
|
|
|
418
|
-
|
|
300
|
+
## SQL Writer
|
|
301
|
+
|
|
302
|
+
Static utility class for generating PostgreSQL DDL:
|
|
419
303
|
|
|
420
304
|
```typescript
|
|
421
|
-
import { Injectable } from '@nestjs/common';
|
|
422
305
|
import { SqlWriter } from '@onivoro/server-typeorm-postgres';
|
|
423
|
-
import {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
306
|
+
import { TableColumnOptions } from 'typeorm';
|
|
307
|
+
|
|
308
|
+
// Add single column
|
|
309
|
+
const addColumnSql = SqlWriter.addColumn('users', {
|
|
310
|
+
name: 'phone_number',
|
|
311
|
+
type: 'varchar',
|
|
312
|
+
length: 20,
|
|
313
|
+
isNullable: true
|
|
314
|
+
});
|
|
315
|
+
// Returns: ALTER TABLE "users" ADD "phone_number" varchar(20)
|
|
316
|
+
|
|
317
|
+
// Create table with multiple columns
|
|
318
|
+
const createTableSql = SqlWriter.createTable('products', [
|
|
319
|
+
{ name: 'id', type: 'serial', isPrimary: true },
|
|
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' }
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
// Drop table
|
|
327
|
+
const dropTableSql = SqlWriter.dropTable('products');
|
|
328
|
+
// Returns: DROP TABLE "products";
|
|
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 }
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
// Drop column
|
|
337
|
+
const dropColumnSql = SqlWriter.dropColumn('products', { name: 'old_column' });
|
|
338
|
+
// Returns: ALTER TABLE "products" DROP COLUMN old_column
|
|
339
|
+
|
|
340
|
+
// Create indexes
|
|
341
|
+
const createIndexSql = SqlWriter.createIndex('products', 'name', false);
|
|
342
|
+
// Returns: CREATE INDEX IF NOT EXISTS products_name ON "products"(name)
|
|
343
|
+
|
|
344
|
+
const createUniqueIndexSql = SqlWriter.createUniqueIndex('products', 'sku');
|
|
345
|
+
// Returns: CREATE UNIQUE INDEX IF NOT EXISTS products_sku ON "products"(sku)
|
|
346
|
+
|
|
347
|
+
// Drop index
|
|
348
|
+
const dropIndexSql = SqlWriter.dropIndex('products_name');
|
|
349
|
+
// Returns: DROP INDEX IF EXISTS products_name
|
|
350
|
+
|
|
351
|
+
// Handle special default values
|
|
352
|
+
const jsonbColumn: TableColumnOptions = {
|
|
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
|
|
367
|
+
```
|
|
457
368
|
|
|
458
|
-
|
|
459
|
-
query.andWhere('u.created_at <= :endDate', { endDate: filters.endDate });
|
|
460
|
-
}
|
|
369
|
+
## Migration Base Classes
|
|
461
370
|
|
|
462
|
-
|
|
463
|
-
query.andWhere("u.metadata->>'segment' = :segment", { segment: filters.segment });
|
|
464
|
-
}
|
|
371
|
+
### TableMigrationBase
|
|
465
372
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
373
|
+
```typescript
|
|
374
|
+
import { TableMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
375
|
+
import { MigrationInterface } from 'typeorm';
|
|
469
376
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
377
|
+
export class CreateUsersTable1234567890 extends TableMigrationBase implements MigrationInterface {
|
|
378
|
+
constructor() {
|
|
379
|
+
super('users', [
|
|
380
|
+
{ name: 'id', type: 'serial', isPrimary: true },
|
|
381
|
+
{ name: 'email', type: 'varchar', length: 255, isUnique: true, isNullable: false },
|
|
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 }
|
|
387
|
+
]);
|
|
474
388
|
}
|
|
389
|
+
}
|
|
390
|
+
```
|
|
475
391
|
|
|
476
|
-
|
|
477
|
-
const queries = {
|
|
478
|
-
totalUsers: this.sqlWriter
|
|
479
|
-
.select('COUNT(*)')
|
|
480
|
-
.from('users')
|
|
481
|
-
.where('deleted_at IS NULL'),
|
|
482
|
-
|
|
483
|
-
activeUsers: this.sqlWriter
|
|
484
|
-
.select('COUNT(*)')
|
|
485
|
-
.from('users')
|
|
486
|
-
.where('deleted_at IS NULL')
|
|
487
|
-
.andWhere('is_active = true'),
|
|
488
|
-
|
|
489
|
-
newUsersThisMonth: this.sqlWriter
|
|
490
|
-
.select('COUNT(*)')
|
|
491
|
-
.from('users')
|
|
492
|
-
.where('deleted_at IS NULL')
|
|
493
|
-
.andWhere("created_at >= DATE_TRUNC('month', CURRENT_DATE)"),
|
|
494
|
-
|
|
495
|
-
totalOrders: this.sqlWriter
|
|
496
|
-
.select('COUNT(*)')
|
|
497
|
-
.from('orders'),
|
|
498
|
-
|
|
499
|
-
totalRevenue: this.sqlWriter
|
|
500
|
-
.select('SUM(total_amount)')
|
|
501
|
-
.from('orders')
|
|
502
|
-
.where("status != 'cancelled'")
|
|
503
|
-
};
|
|
392
|
+
### ColumnMigrationBase
|
|
504
393
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
key,
|
|
508
|
-
await query.getRawOne()
|
|
509
|
-
])
|
|
510
|
-
);
|
|
394
|
+
```typescript
|
|
395
|
+
import { ColumnMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
511
396
|
|
|
512
|
-
|
|
397
|
+
export class AddUserPhoneNumber1234567891 extends ColumnMigrationBase {
|
|
398
|
+
constructor() {
|
|
399
|
+
super('users', {
|
|
400
|
+
name: 'phone_number',
|
|
401
|
+
type: 'varchar',
|
|
402
|
+
length: 20,
|
|
403
|
+
isNullable: true
|
|
404
|
+
});
|
|
513
405
|
}
|
|
514
406
|
}
|
|
515
407
|
```
|
|
516
408
|
|
|
517
|
-
###
|
|
409
|
+
### ColumnsMigrationBase
|
|
518
410
|
|
|
519
411
|
```typescript
|
|
520
|
-
import {
|
|
521
|
-
Table,
|
|
522
|
-
PrimaryTableColumn,
|
|
523
|
-
TableColumn,
|
|
524
|
-
NullableTableColumn,
|
|
525
|
-
ManyToOneRelationOptions
|
|
526
|
-
} from '@onivoro/server-typeorm-postgres';
|
|
527
|
-
import { Entity, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
|
|
528
|
-
|
|
529
|
-
@Entity()
|
|
530
|
-
@Table('orders')
|
|
531
|
-
@Index(['userId', 'status'])
|
|
532
|
-
@Index(['createdAt'])
|
|
533
|
-
export class Order {
|
|
534
|
-
@PrimaryTableColumn()
|
|
535
|
-
id: number;
|
|
412
|
+
import { ColumnsMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
536
413
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']
|
|
546
|
-
})
|
|
547
|
-
status: string;
|
|
548
|
-
|
|
549
|
-
@TableColumn({ type: 'int' })
|
|
550
|
-
userId: number;
|
|
551
|
-
|
|
552
|
-
@TableColumn({ type: 'jsonb', default: '{}' })
|
|
553
|
-
metadata: Record<string, any>;
|
|
554
|
-
|
|
555
|
-
@TableColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
|
556
|
-
createdAt: Date;
|
|
557
|
-
|
|
558
|
-
@NullableTableColumn({ type: 'timestamp' })
|
|
559
|
-
shippedAt?: Date;
|
|
560
|
-
|
|
561
|
-
@NullableTableColumn({ type: 'timestamp' })
|
|
562
|
-
deliveredAt?: Date;
|
|
563
|
-
|
|
564
|
-
@NullableTableColumn({ type: 'timestamp' })
|
|
565
|
-
cancelledAt?: Date;
|
|
566
|
-
|
|
567
|
-
// Full-text search column
|
|
568
|
-
@TableColumn({ type: 'tsvector', select: false })
|
|
569
|
-
searchVector: string;
|
|
570
|
-
|
|
571
|
-
// Relationships
|
|
572
|
-
@ManyToOne(() => User, user => user.orders, ManyToOneRelationOptions)
|
|
573
|
-
@JoinColumn({ name: 'userId' })
|
|
574
|
-
user: User;
|
|
575
|
-
|
|
576
|
-
@OneToMany(() => OrderItem, orderItem => orderItem.order, { cascade: true })
|
|
577
|
-
items: OrderItem[];
|
|
414
|
+
export class AddUserContactInfo1234567892 extends ColumnsMigrationBase {
|
|
415
|
+
constructor() {
|
|
416
|
+
super('users', [
|
|
417
|
+
{ name: 'phone_number', type: 'varchar', length: 20, isNullable: true },
|
|
418
|
+
{ name: 'secondary_email', type: 'varchar', length: 255, isNullable: true },
|
|
419
|
+
{ name: 'address', type: 'jsonb', isNullable: true }
|
|
420
|
+
]);
|
|
421
|
+
}
|
|
578
422
|
}
|
|
423
|
+
```
|
|
579
424
|
|
|
580
|
-
|
|
581
|
-
@Table('order_items')
|
|
582
|
-
@Index(['orderId', 'productId'])
|
|
583
|
-
export class OrderItem {
|
|
584
|
-
@PrimaryTableColumn()
|
|
585
|
-
id: number;
|
|
586
|
-
|
|
587
|
-
@TableColumn({ type: 'int' })
|
|
588
|
-
orderId: number;
|
|
589
|
-
|
|
590
|
-
@TableColumn({ type: 'int' })
|
|
591
|
-
productId: number;
|
|
425
|
+
### IndexMigrationBase
|
|
592
426
|
|
|
593
|
-
|
|
594
|
-
|
|
427
|
+
```typescript
|
|
428
|
+
import { IndexMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
595
429
|
|
|
596
|
-
|
|
597
|
-
|
|
430
|
+
export class CreateUserEmailIndex1234567893 extends IndexMigrationBase {
|
|
431
|
+
constructor() {
|
|
432
|
+
super('users', 'email', true); // table, column, unique
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
```
|
|
598
436
|
|
|
599
|
-
|
|
600
|
-
totalPrice: number;
|
|
437
|
+
### DropTableMigrationBase & DropColumnMigrationBase
|
|
601
438
|
|
|
602
|
-
|
|
603
|
-
|
|
439
|
+
```typescript
|
|
440
|
+
import { DropTableMigrationBase, DropColumnMigrationBase } from '@onivoro/server-typeorm-postgres';
|
|
604
441
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
442
|
+
export class DropLegacyUsersTable1234567894 extends DropTableMigrationBase {
|
|
443
|
+
constructor() {
|
|
444
|
+
super('legacy_users');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
608
447
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
448
|
+
export class DropUserMiddleName1234567895 extends DropColumnMigrationBase {
|
|
449
|
+
constructor() {
|
|
450
|
+
super('users', { name: 'middle_name' });
|
|
451
|
+
}
|
|
612
452
|
}
|
|
613
453
|
```
|
|
614
454
|
|
|
615
|
-
|
|
455
|
+
## Building Repositories from Metadata
|
|
456
|
+
|
|
457
|
+
Both TypeOrmRepository and RedshiftRepository support building instances from metadata:
|
|
616
458
|
|
|
617
459
|
```typescript
|
|
618
|
-
import {
|
|
460
|
+
import { TypeOrmRepository, RedshiftRepository } from '@onivoro/server-typeorm-postgres';
|
|
619
461
|
import { DataSource } from 'typeorm';
|
|
620
462
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
// Add tsvector column if it doesn't exist
|
|
630
|
-
await this.dataSource.query(`
|
|
631
|
-
ALTER TABLE ${tableName}
|
|
632
|
-
ADD COLUMN IF NOT EXISTS ${vectorColumn} tsvector
|
|
633
|
-
`);
|
|
634
|
-
|
|
635
|
-
// Create trigger to update search vector
|
|
636
|
-
await this.dataSource.query(`
|
|
637
|
-
CREATE OR REPLACE FUNCTION update_${tableName}_search_vector()
|
|
638
|
-
RETURNS trigger AS $$
|
|
639
|
-
BEGIN
|
|
640
|
-
NEW.${vectorColumn} := to_tsvector('english', ${columns.map(col => `COALESCE(NEW.${col}, '')`).join(" || ' ' || ")});
|
|
641
|
-
RETURN NEW;
|
|
642
|
-
END;
|
|
643
|
-
$$ LANGUAGE plpgsql;
|
|
644
|
-
`);
|
|
645
|
-
|
|
646
|
-
// Create trigger
|
|
647
|
-
await this.dataSource.query(`
|
|
648
|
-
DROP TRIGGER IF EXISTS trigger_${tableName}_search_vector ON ${tableName};
|
|
649
|
-
CREATE TRIGGER trigger_${tableName}_search_vector
|
|
650
|
-
BEFORE INSERT OR UPDATE ON ${tableName}
|
|
651
|
-
FOR EACH ROW EXECUTE FUNCTION update_${tableName}_search_vector();
|
|
652
|
-
`);
|
|
653
|
-
|
|
654
|
-
// Create GIN index
|
|
655
|
-
await this.dataSource.query(`
|
|
656
|
-
CREATE INDEX IF NOT EXISTS ${indexName}
|
|
657
|
-
ON ${tableName} USING gin(${vectorColumn})
|
|
658
|
-
`);
|
|
659
|
-
|
|
660
|
-
// Update existing records
|
|
661
|
-
await this.dataSource.query(`
|
|
662
|
-
UPDATE ${tableName}
|
|
663
|
-
SET ${vectorColumn} = to_tsvector('english', ${columns.map(col => `COALESCE(${col}, '')`).join(" || ' ' || ")})
|
|
664
|
-
`);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
async performFullTextSearch(
|
|
668
|
-
tableName: string,
|
|
669
|
-
searchTerm: string,
|
|
670
|
-
limit: number = 10
|
|
671
|
-
): Promise<any[]> {
|
|
672
|
-
const vectorColumn = `${tableName}_search_vector`;
|
|
673
|
-
|
|
674
|
-
return this.dataSource.query(`
|
|
675
|
-
SELECT *,
|
|
676
|
-
ts_rank(${vectorColumn}, plainto_tsquery('english', $1)) as rank
|
|
677
|
-
FROM ${tableName}
|
|
678
|
-
WHERE ${vectorColumn} @@ plainto_tsquery('english', $1)
|
|
679
|
-
ORDER BY rank DESC
|
|
680
|
-
LIMIT $2
|
|
681
|
-
`, [searchTerm, limit]);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async createPartitionedTable(
|
|
685
|
-
tableName: string,
|
|
686
|
-
partitionColumn: string,
|
|
687
|
-
partitionType: 'RANGE' | 'LIST' | 'HASH' = 'RANGE'
|
|
688
|
-
): Promise<void> {
|
|
689
|
-
await this.dataSource.query(`
|
|
690
|
-
CREATE TABLE ${tableName}_partitioned (
|
|
691
|
-
LIKE ${tableName} INCLUDING ALL
|
|
692
|
-
) PARTITION BY ${partitionType} (${partitionColumn})
|
|
693
|
-
`);
|
|
694
|
-
}
|
|
463
|
+
// Define your entity type
|
|
464
|
+
interface UserEvent {
|
|
465
|
+
id: number;
|
|
466
|
+
userId: number;
|
|
467
|
+
eventType: string;
|
|
468
|
+
eventData: any;
|
|
469
|
+
createdAt: Date;
|
|
470
|
+
}
|
|
695
471
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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'
|
|
717
511
|
}
|
|
718
512
|
}
|
|
513
|
+
});
|
|
719
514
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
async analyzeTableStatistics(tableName: string): Promise<any> {
|
|
728
|
-
return this.dataSource.query(`
|
|
729
|
-
SELECT
|
|
730
|
-
schemaname,
|
|
731
|
-
tablename,
|
|
732
|
-
attname,
|
|
733
|
-
n_distinct,
|
|
734
|
-
most_common_vals,
|
|
735
|
-
most_common_freqs,
|
|
736
|
-
histogram_bounds
|
|
737
|
-
FROM pg_stats
|
|
738
|
-
WHERE tablename = $1
|
|
739
|
-
`, [tableName]);
|
|
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
|
|
740
521
|
}
|
|
522
|
+
});
|
|
741
523
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
pg_size_pretty(pg_total_relation_size($1)) as total_size,
|
|
746
|
-
pg_size_pretty(pg_relation_size($1)) as table_size,
|
|
747
|
-
pg_size_pretty(pg_indexes_size($1)) as indexes_size
|
|
748
|
-
`, [tableName]);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
524
|
+
// Use the repositories
|
|
525
|
+
const events = await userEventRepo.getMany({ where: { userId: 123 } });
|
|
526
|
+
const recentEvent = await userEventRepo.getOne({ where: { id: 456 } });
|
|
751
527
|
```
|
|
752
528
|
|
|
753
|
-
##
|
|
754
|
-
|
|
755
|
-
### Repository Classes
|
|
756
|
-
|
|
757
|
-
#### TypeOrmRepository<T>
|
|
758
|
-
|
|
759
|
-
Base repository class with PostgreSQL optimizations:
|
|
529
|
+
## Data Source Configuration
|
|
760
530
|
|
|
761
531
|
```typescript
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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]);
|
|
770
553
|
|
|
771
|
-
|
|
772
|
-
export class TypeOrmPagingRepository<T> extends TypeOrmRepository<T> {
|
|
773
|
-
async findWithPaging(
|
|
774
|
-
options: FindManyOptions<T>,
|
|
775
|
-
pageParams: PageParams
|
|
776
|
-
): Promise<PagedData<T>>
|
|
777
|
-
}
|
|
554
|
+
const dataSource = new DataSource(config);
|
|
778
555
|
```
|
|
779
556
|
|
|
780
|
-
|
|
557
|
+
## Type Definitions
|
|
781
558
|
|
|
782
|
-
|
|
559
|
+
### Core Types
|
|
783
560
|
|
|
784
561
|
```typescript
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
562
|
+
// Table metadata
|
|
563
|
+
interface TTableMeta {
|
|
564
|
+
databasePath: string;
|
|
565
|
+
type: string;
|
|
566
|
+
propertyPath: string;
|
|
567
|
+
isPrimary: boolean;
|
|
568
|
+
default?: any;
|
|
788
569
|
}
|
|
789
|
-
```
|
|
790
|
-
|
|
791
|
-
### Migration Base Classes
|
|
792
570
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
```typescript
|
|
798
|
-
export abstract class TableMigrationBase {
|
|
799
|
-
protected createTable(queryRunner: QueryRunner, tableName: string, columns: ColumnDefinition[]): Promise<void>
|
|
800
|
-
protected dropTable(queryRunner: QueryRunner, tableName: string): Promise<void>
|
|
801
|
-
protected createIndex(queryRunner: QueryRunner, tableName: string, columns: string[]): Promise<void>
|
|
571
|
+
// Page parameters
|
|
572
|
+
interface IPageParams {
|
|
573
|
+
page: number;
|
|
574
|
+
limit: number;
|
|
802
575
|
}
|
|
803
|
-
```
|
|
804
576
|
|
|
805
|
-
|
|
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
|
+
}
|
|
806
587
|
|
|
807
|
-
|
|
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
|
+
}
|
|
808
598
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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>;
|
|
814
610
|
}
|
|
815
611
|
```
|
|
816
612
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
#### SqlWriter
|
|
820
|
-
|
|
821
|
-
Advanced SQL query builder:
|
|
613
|
+
## Utility Functions
|
|
822
614
|
|
|
823
615
|
```typescript
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
groupBy(columns: string[]): SqlWriter
|
|
832
|
-
orderBy(column: string, direction?: 'ASC' | 'DESC'): SqlWriter
|
|
833
|
-
execute(): Promise<any[]>
|
|
834
|
-
}
|
|
835
|
-
```
|
|
616
|
+
import {
|
|
617
|
+
getSkip,
|
|
618
|
+
getPagingKey,
|
|
619
|
+
removeFalseyKeys,
|
|
620
|
+
generateDateQuery,
|
|
621
|
+
getApiTypeFromColumn
|
|
622
|
+
} from '@onivoro/server-typeorm-postgres';
|
|
836
623
|
|
|
837
|
-
|
|
624
|
+
// Calculate skip value for pagination
|
|
625
|
+
const skip = getSkip(2, 20); // page 2, limit 20 = skip 20
|
|
838
626
|
|
|
839
|
-
|
|
627
|
+
// Generate cache key for pagination
|
|
628
|
+
const cacheKey = getPagingKey(2, 20); // Returns: "page_2_limit_20"
|
|
840
629
|
|
|
841
|
-
|
|
630
|
+
// Remove falsey values from object
|
|
631
|
+
const cleanedFilters = removeFalseyKeys({
|
|
632
|
+
name: 'John',
|
|
633
|
+
age: 0, // Removed
|
|
634
|
+
active: false, // Kept (false is not falsey for this function)
|
|
635
|
+
email: '', // Removed
|
|
636
|
+
dept: null // Removed
|
|
637
|
+
});
|
|
842
638
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
639
|
+
// Generate date range query
|
|
640
|
+
const dateQuery = generateDateQuery('created_at', {
|
|
641
|
+
startDate: new Date('2024-01-01'),
|
|
642
|
+
endDate: new Date('2024-12-31')
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Get API type from TypeORM column metadata
|
|
646
|
+
const apiType = getApiTypeFromColumn(columnMetadata);
|
|
850
647
|
```
|
|
851
648
|
|
|
852
649
|
## Best Practices
|
|
853
650
|
|
|
854
|
-
1. **
|
|
855
|
-
2. **
|
|
856
|
-
3. **
|
|
857
|
-
4. **
|
|
858
|
-
5. **
|
|
859
|
-
6. **
|
|
860
|
-
7. **
|
|
861
|
-
8. **
|
|
862
|
-
|
|
863
|
-
## Performance Optimization
|
|
864
|
-
|
|
865
|
-
```typescript
|
|
866
|
-
// Example of optimized queries
|
|
867
|
-
const optimizedQuery = repository
|
|
868
|
-
.createQueryBuilder('user')
|
|
869
|
-
.select(['user.id', 'user.email']) // Select only needed columns
|
|
870
|
-
.where('user.isActive = :active', { active: true })
|
|
871
|
-
.andWhere('user.createdAt > :date', { date: cutoffDate })
|
|
872
|
-
.orderBy('user.createdAt', 'DESC')
|
|
873
|
-
.limit(100)
|
|
874
|
-
.getMany();
|
|
875
|
-
|
|
876
|
-
// Use indexes for better performance
|
|
877
|
-
@Index(['email']) // Single column index
|
|
878
|
-
@Index(['isActive', 'createdAt']) // Composite index
|
|
879
|
-
export class User {
|
|
880
|
-
// Entity definition
|
|
881
|
-
}
|
|
882
|
-
```
|
|
651
|
+
1. **Repository Pattern**: Extend TypeOrmRepository for standard PostgreSQL operations
|
|
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
|
|
883
659
|
|
|
884
660
|
## Testing
|
|
885
661
|
|
|
886
662
|
```typescript
|
|
887
663
|
import { Test } from '@nestjs/testing';
|
|
888
|
-
import {
|
|
889
|
-
import {
|
|
664
|
+
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
665
|
+
import { EntityManager } from 'typeorm';
|
|
666
|
+
import { UserRepository } from './user.repository';
|
|
890
667
|
import { User } from './user.entity';
|
|
891
|
-
import { UserService } from './user.service';
|
|
892
668
|
|
|
893
|
-
describe('
|
|
894
|
-
let
|
|
895
|
-
let
|
|
669
|
+
describe('UserRepository', () => {
|
|
670
|
+
let repository: UserRepository;
|
|
671
|
+
let entityManager: EntityManager;
|
|
896
672
|
|
|
897
673
|
beforeEach(async () => {
|
|
898
674
|
const module = await Test.createTestingModule({
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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])
|
|
905
687
|
],
|
|
688
|
+
providers: [UserRepository],
|
|
906
689
|
}).compile();
|
|
907
690
|
|
|
908
|
-
|
|
909
|
-
repository =
|
|
691
|
+
entityManager = module.get<EntityManager>(EntityManager);
|
|
692
|
+
repository = new UserRepository(entityManager);
|
|
910
693
|
});
|
|
911
694
|
|
|
912
|
-
it('should
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
695
|
+
it('should create and retrieve user', async () => {
|
|
696
|
+
const userData = {
|
|
697
|
+
email: 'test@example.com',
|
|
698
|
+
firstName: 'John',
|
|
699
|
+
isActive: true
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const user = await repository.postOne(userData);
|
|
703
|
+
expect(user.id).toBeDefined();
|
|
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
|
+
});
|
|
916
722
|
});
|
|
917
723
|
});
|
|
918
724
|
```
|