@geekmidas/testkit 0.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 ADDED
@@ -0,0 +1,383 @@
1
+ # @geekmidas/testkit
2
+
3
+ > Type-safe testing utilities and database factories for modern TypeScript applications
4
+
5
+ [![Node Version](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen)](https://nodejs.org)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue)](https://www.typescriptlang.org)
7
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
+
9
+ ## 🚀 Overview
10
+
11
+ **@geekmidas/testkit** provides a comprehensive set of testing utilities designed to simplify database testing in TypeScript applications. It offers factory patterns for creating test data, supports multiple database libraries, and ensures type safety throughout your tests.
12
+
13
+ ### Key Features
14
+
15
+ - 🏭 **Factory Pattern**: Create test data with minimal boilerplate
16
+ - 🔒 **Type Safety**: Full TypeScript support with automatic schema inference
17
+ - 🗄️ **Multi-Database Support**: Works with Kysely and Objection.js
18
+ - 🔄 **Transaction Isolation**: Built-in support for test isolation
19
+ - 🚀 **Performance**: Efficient batch operations and data seeding
20
+ - 🧩 **Flexible**: Extensible architecture for custom implementations
21
+
22
+ ## 📦 Installation
23
+
24
+ ```bash
25
+ npm install --save-dev @geekmidas/testkit
26
+ # or
27
+ pnpm add -D @geekmidas/testkit
28
+ # or
29
+ yarn add -D @geekmidas/testkit
30
+ ```
31
+
32
+ ## 🛠️ Quick Start
33
+
34
+ ### With Kysely
35
+
36
+ ```typescript
37
+ import { KyselyFactory } from '@geekmidas/testkit/kysely';
38
+ import { Kysely } from 'kysely';
39
+
40
+ // Define your database schema
41
+ interface Database {
42
+ users: {
43
+ id: number;
44
+ name: string;
45
+ email: string;
46
+ createdAt: Date;
47
+ };
48
+ posts: {
49
+ id: number;
50
+ title: string;
51
+ content: string;
52
+ userId: number;
53
+ publishedAt: Date | null;
54
+ };
55
+ }
56
+
57
+ // Create builders for your tables
58
+ const userBuilder = KyselyFactory.createBuilder<Database, 'users'>({
59
+ table: 'users',
60
+ defaults: async () => ({
61
+ name: 'John Doe',
62
+ email: `user${Date.now()}@example.com`,
63
+ createdAt: new Date(),
64
+ }),
65
+ });
66
+
67
+ const postBuilder = KyselyFactory.createBuilder<Database, 'posts'>({
68
+ table: 'posts',
69
+ defaults: async () => ({
70
+ title: 'Test Post',
71
+ content: 'Lorem ipsum dolor sit amet',
72
+ publishedAt: null,
73
+ }),
74
+ });
75
+
76
+ // Initialize factory
77
+ const builders = { user: userBuilder, post: postBuilder };
78
+ const factory = new KyselyFactory(builders, {}, db);
79
+
80
+ // Use in tests
81
+ describe('User Service', () => {
82
+ it('should create a user with posts', async () => {
83
+ const user = await factory.insert('user', {
84
+ name: 'Jane Smith',
85
+ email: 'jane@example.com',
86
+ });
87
+
88
+ const posts = await factory.insertMany(3, 'post', {
89
+ userId: user.id,
90
+ });
91
+
92
+ expect(posts).toHaveLength(3);
93
+ expect(posts[0].userId).toBe(user.id);
94
+ });
95
+ });
96
+ ```
97
+
98
+ ### With Objection.js
99
+
100
+ ```typescript
101
+ import { ObjectionFactory } from '@geekmidas/testkit/objection';
102
+ import { Model } from 'objection';
103
+
104
+ // Define your models
105
+ class User extends Model {
106
+ static tableName = 'users';
107
+ id!: number;
108
+ name!: string;
109
+ email!: string;
110
+ }
111
+
112
+ class Post extends Model {
113
+ static tableName = 'posts';
114
+ id!: number;
115
+ title!: string;
116
+ userId!: number;
117
+ }
118
+
119
+ // Create builders
120
+ const userBuilder = {
121
+ table: 'users',
122
+ model: User,
123
+ defaults: async () => ({
124
+ name: 'John Doe',
125
+ email: `user${Date.now()}@example.com`,
126
+ }),
127
+ };
128
+
129
+ // Use in tests
130
+ const factory = new ObjectionFactory({ user: userBuilder }, {});
131
+ const user = await factory.insert('user', { name: 'Jane Doe' });
132
+ ```
133
+
134
+ ## 🏗️ Core Concepts
135
+
136
+ ### Builders
137
+
138
+ Builders define how to create test data for each table. They specify:
139
+
140
+ - **Table name**: The database table to insert into
141
+ - **Default values**: Function returning default attributes
142
+ - **Transformations**: Optional data transformations before insertion
143
+ - **Relations**: Optional related data to create after insertion
144
+
145
+ ```typescript
146
+ const userBuilder = KyselyFactory.createBuilder<Database, 'users'>({
147
+ table: 'users',
148
+ defaults: async () => ({
149
+ id: generateId(),
150
+ name: faker.person.fullName(),
151
+ email: faker.internet.email(),
152
+ createdAt: new Date(),
153
+ }),
154
+ transform: async (data) => ({
155
+ ...data,
156
+ email: data.email.toLowerCase(),
157
+ }),
158
+ relations: async (user, factory) => {
159
+ // Create related data after user insertion
160
+ await factory.insert('profile', { userId: user.id });
161
+ },
162
+ });
163
+ ```
164
+
165
+ ### Seeds
166
+
167
+ Seeds are functions that create complex test scenarios with multiple related entities:
168
+
169
+ ```typescript
170
+ const blogSeed = async (factory: Factory) => {
171
+ const author = await factory.insert('user', {
172
+ name: 'Blog Author',
173
+ role: 'author',
174
+ });
175
+
176
+ const categories = await factory.insertMany(3, 'category');
177
+
178
+ const posts = await factory.insertMany(5, 'post', (index) => ({
179
+ title: `Post ${index + 1}`,
180
+ authorId: author.id,
181
+ categoryId: categories[index % categories.length].id,
182
+ }));
183
+
184
+ return { author, categories, posts };
185
+ };
186
+
187
+ // Use in tests
188
+ const data = await factory.seed('blog');
189
+ ```
190
+
191
+ ### Transaction Support
192
+
193
+ TestKit supports transaction-based test isolation:
194
+
195
+ ```typescript
196
+ describe('User Service', () => {
197
+ let trx: Transaction<Database>;
198
+ let factory: KyselyFactory;
199
+
200
+ beforeEach(async () => {
201
+ trx = await db.transaction();
202
+ factory = new KyselyFactory(builders, seeds, trx);
203
+ });
204
+
205
+ afterEach(async () => {
206
+ await trx.rollback();
207
+ });
208
+
209
+ it('should perform operations in isolation', async () => {
210
+ const user = await factory.insert('user');
211
+ // Test operations...
212
+ // All changes will be rolled back after the test
213
+ });
214
+ });
215
+ ```
216
+
217
+ ## 📚 Advanced Usage
218
+
219
+ ### Database Migration
220
+
221
+ TestKit includes utilities for managing test database migrations:
222
+
223
+ ```typescript
224
+ import { PostgresKyselyMigrator } from '@geekmidas/testkit/migrator/postgres-kysely';
225
+
226
+ const migrator = new PostgresKyselyMigrator({
227
+ database: 'test_db',
228
+ connection: {
229
+ host: 'localhost',
230
+ port: 5432,
231
+ user: 'postgres',
232
+ password: 'password',
233
+ },
234
+ migrationFolder: './migrations',
235
+ });
236
+
237
+ // In test setup
238
+ beforeAll(async () => {
239
+ const cleanup = await migrator.start();
240
+ // Database is created and migrations are run
241
+
242
+ // Store cleanup function for later
243
+ globalThis.cleanupDb = cleanup;
244
+ });
245
+
246
+ afterAll(async () => {
247
+ await globalThis.cleanupDb?.();
248
+ // Database is dropped
249
+ });
250
+ ```
251
+
252
+ ### Custom Factories
253
+
254
+ You can extend the base Factory class for custom implementations:
255
+
256
+ ```typescript
257
+ import { Factory } from '@geekmidas/testkit/factory';
258
+
259
+ class MongoFactory extends Factory {
260
+ async performInsert(table: string, data: any) {
261
+ const collection = this.db.collection(table);
262
+ const result = await collection.insertOne(data);
263
+ return { ...data, _id: result.insertedId };
264
+ }
265
+
266
+ async performInsertMany(table: string, data: any[]) {
267
+ const collection = this.db.collection(table);
268
+ const result = await collection.insertMany(data);
269
+ return data.map((item, index) => ({
270
+ ...item,
271
+ _id: result.insertedIds[index],
272
+ }));
273
+ }
274
+ }
275
+ ```
276
+
277
+ ### Dynamic Attributes
278
+
279
+ Create dynamic attributes for each record in batch operations:
280
+
281
+ ```typescript
282
+ const users = await factory.insertMany(10, 'user', (index) => ({
283
+ name: `User ${index + 1}`,
284
+ email: `user${index + 1}@example.com`,
285
+ isAdmin: index === 0, // First user is admin
286
+ }));
287
+ ```
288
+
289
+ ### Conditional Auto-insertion
290
+
291
+ Control whether builders automatically insert data:
292
+
293
+ ```typescript
294
+ const draftBuilder = KyselyFactory.createBuilder<Database, 'posts'>({
295
+ table: 'posts',
296
+ defaults: async () => ({
297
+ title: 'Draft Post',
298
+ status: 'draft',
299
+ }),
300
+ autoInsert: false, // Don't insert automatically
301
+ });
302
+
303
+ // Manually handle the data
304
+ const draftData = await draftBuilder.build();
305
+ // Perform validation or modifications...
306
+ const post = await db.insertInto('posts').values(draftData).execute();
307
+ ```
308
+
309
+ ## 🔧 API Reference
310
+
311
+ ### KyselyFactory
312
+
313
+ ```typescript
314
+ class KyselyFactory<TBuilders, TSeeds> extends Factory {
315
+ constructor(
316
+ builders: TBuilders,
317
+ seeds: TSeeds,
318
+ db: Kysely<any> | Transaction<any>
319
+ );
320
+
321
+ static createBuilder<TDatabase, TTable>(
322
+ config: BuilderConfig<TDatabase, TTable>
323
+ ): Builder;
324
+
325
+ insert<K extends keyof TBuilders>(
326
+ name: K,
327
+ overrides?: Partial<BuilderOutput>
328
+ ): Promise<BuilderOutput>;
329
+
330
+ insertMany<K extends keyof TBuilders>(
331
+ count: number,
332
+ name: K,
333
+ overrides?: Partial<BuilderOutput> | ((index: number) => Partial<BuilderOutput>)
334
+ ): Promise<BuilderOutput[]>;
335
+
336
+ seed<K extends keyof TSeeds>(
337
+ name: K,
338
+ ...args: Parameters<TSeeds[K]>
339
+ ): Promise<ReturnType<TSeeds[K]>>;
340
+ }
341
+ ```
342
+
343
+ ### ObjectionFactory
344
+
345
+ ```typescript
346
+ class ObjectionFactory<TBuilders, TSeeds> extends Factory {
347
+ constructor(
348
+ builders: TBuilders,
349
+ seeds: TSeeds,
350
+ knex?: Knex
351
+ );
352
+
353
+ // Same methods as KyselyFactory
354
+ }
355
+ ```
356
+
357
+ ### Builder Configuration
358
+
359
+ ```typescript
360
+ interface BuilderConfig<TDatabase, TTable> {
361
+ table: TTable;
362
+ defaults: () => Promise<Insertable<TDatabase[TTable]>>;
363
+ transform?: (data: any) => Promise<any>;
364
+ relations?: (inserted: any, factory: Factory) => Promise<void>;
365
+ autoInsert?: boolean;
366
+ }
367
+ ```
368
+
369
+ ## 🧪 Testing Best Practices
370
+
371
+ 1. **Use Transactions**: Always wrap tests in transactions for isolation
372
+ 2. **Create Minimal Data**: Only create the data necessary for each test
373
+ 3. **Use Seeds for Complex Scenarios**: Encapsulate complex setups in seeds
374
+ 4. **Leverage Type Safety**: Let TypeScript catch schema mismatches
375
+ 5. **Clean Up Resources**: Always clean up database connections and transactions
376
+
377
+ ## 🤝 Contributing
378
+
379
+ We welcome contributions! Please see our [Contributing Guide](../../CONTRIBUTING.md) for details.
380
+
381
+ ## 📄 License
382
+
383
+ This project is licensed under the MIT License - see the [LICENSE](../../LICENSE) file for details.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@geekmidas/testkit",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ "./objection": "./src/ObjectionFactory.ts",
8
+ "./kysely": "./src/KyselyFactory.ts"
9
+ },
10
+ "dependencies": {},
11
+ "devDependencies": {
12
+ "@types/pg": "~8.15.4"
13
+ },
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org/",
16
+ "access": "public"
17
+ },
18
+ "peerDependencies": {
19
+ "kysely": "~0.28.2",
20
+ "pg": "~8.16.3",
21
+ "knex": "~3.1.0",
22
+ "objection": "~3.1.5",
23
+ "db-errors": "~0.2.3",
24
+ "@geekmidas/envkit": "0.0.1"
25
+ }
26
+ }
package/src/Factory.ts ADDED
@@ -0,0 +1,58 @@
1
+ export abstract class Factory<
2
+ Builders extends Record<string, any>,
3
+ Seeds extends Record<string, any>,
4
+ > {
5
+ static createSeed<Seed extends FactorySeed>(seedFn: Seed): Seed {
6
+ return seedFn;
7
+ }
8
+ /**
9
+ * Inserts an object into the database using a builder function.
10
+ *
11
+ * @param builderName - The name of the builder to use
12
+ * @param attrs - The attributes to insert
13
+ */
14
+ abstract insert<K extends keyof Builders>(
15
+ builderName: K,
16
+ attrs?: Parameters<Builders[K]>[0],
17
+ ): Promise<Awaited<ReturnType<Builders[K]>>>;
18
+
19
+ /**
20
+ * Inserts multiple objects into the database
21
+ *
22
+ * @param count - Number of objects to insert
23
+ * @param builderName - The name of the builder to use
24
+ * @param attrs - The attributes to insert
25
+ */
26
+ abstract insertMany<K extends keyof Builders>(
27
+ count: number,
28
+ builderName: K,
29
+ attrs?:
30
+ | Parameters<Builders[K]>[0]
31
+ | ((idx: number) => Parameters<Builders[K]>[0]),
32
+ ): Promise<Awaited<ReturnType<Builders[K]>>[]>;
33
+
34
+ /**
35
+ * Seeds the database using a seed function.
36
+ *
37
+ * @param seedName - The name of the seed to use
38
+ * @returns The result of the seed function
39
+ * @param attrs - The attributes to pass to the seed function
40
+ */
41
+ abstract seed<K extends keyof Seeds>(
42
+ seedName: K,
43
+ attrs?: Parameters<Seeds[K]>[0],
44
+ ): ReturnType<Seeds[K]>;
45
+ }
46
+
47
+ export type MixedFactoryBuilder<
48
+ Attrs = any,
49
+ Factory = any,
50
+ Result = any,
51
+ DB = any,
52
+ > = (attrs: Attrs, factory: Factory, db: DB) => Result | Promise<Result>;
53
+
54
+ export type FactorySeed<Attrs = any, Factory = any, Result = any, DB = any> = (
55
+ attrs: Attrs,
56
+ factory: Factory,
57
+ db: DB,
58
+ ) => Promise<Result>;
@@ -0,0 +1,183 @@
1
+ import type {
2
+ ControlledTransaction,
3
+ Insertable,
4
+ Kysely,
5
+ Selectable,
6
+ } from 'kysely';
7
+ import { Factory, type FactorySeed } from './Factory.ts';
8
+
9
+ export class KyselyFactory<
10
+ DB,
11
+ Builders extends Record<string, any>,
12
+ Seeds extends Record<string, any>,
13
+ > extends Factory<Builders, Seeds> {
14
+ static createSeed<Seed extends FactorySeed>(seedFn: Seed): Seed {
15
+ return Factory.createSeed(seedFn);
16
+ }
17
+
18
+ constructor(
19
+ private builders: Builders,
20
+ private seeds: Seeds,
21
+ private db: Kysely<DB> | ControlledTransaction<DB, []>,
22
+ ) {
23
+ super();
24
+ }
25
+
26
+ static createBuilder<
27
+ DB,
28
+ TableName extends keyof DB & string,
29
+ Attrs extends Partial<Insertable<DB[TableName]>> = Partial<
30
+ Insertable<DB[TableName]>
31
+ >,
32
+ Factory = any,
33
+ Result = Selectable<DB[TableName]>,
34
+ >(config: {
35
+ table: TableName;
36
+ defaults?: (
37
+ attrs: Attrs,
38
+ factory: Factory,
39
+ db: Kysely<DB>,
40
+ ) =>
41
+ | Partial<Insertable<DB[TableName]>>
42
+ | Promise<Partial<Insertable<DB[TableName]>>>;
43
+ transform?: (
44
+ data: Partial<Insertable<DB[TableName]>>,
45
+ factory: Factory,
46
+ db: Kysely<DB>,
47
+ ) =>
48
+ | Partial<Insertable<DB[TableName]>>
49
+ | Promise<Partial<Insertable<DB[TableName]>>>;
50
+ relations?: (
51
+ record: Result,
52
+ attrs: Attrs,
53
+ factory: Factory,
54
+ db: Kysely<DB>,
55
+ ) => Promise<void>;
56
+ autoInsert?: boolean;
57
+ }): (attrs: Attrs, factory: Factory, db: Kysely<DB>) => Promise<Result> {
58
+ return async (attrs: Attrs, factory: Factory, db: Kysely<DB>) => {
59
+ // Start with attributes
60
+ let data: Partial<Insertable<DB[TableName]>> = { ...attrs };
61
+
62
+ // Apply defaults
63
+ if (config.defaults) {
64
+ const defaults = await config.defaults(attrs, factory, db);
65
+ data = { ...defaults, ...data };
66
+ }
67
+
68
+ // Apply transformations
69
+ if (config.transform) {
70
+ data = await config.transform(data, factory, db);
71
+ }
72
+
73
+ // Handle insertion based on autoInsert flag
74
+ if (config.autoInsert !== false) {
75
+ // Auto insert is enabled by default
76
+ const result = await db
77
+ .insertInto(config.table)
78
+ .values(data as Insertable<DB[TableName]>)
79
+ .returningAll()
80
+ .executeTakeFirst();
81
+
82
+ if (!result) {
83
+ throw new Error(`Failed to insert into ${config.table}`);
84
+ }
85
+
86
+ // Handle relations if defined
87
+ if (config.relations) {
88
+ await config.relations(result as Result, attrs, factory, db);
89
+ }
90
+
91
+ return result as Result;
92
+ } else {
93
+ // Return object for factory to handle insertion
94
+ return { table: config.table, data } as any;
95
+ }
96
+ };
97
+ }
98
+
99
+ async insert<K extends keyof Builders>(
100
+ builderName: K,
101
+ attrs?: Parameters<Builders[K]>[0],
102
+ ): Promise<Awaited<ReturnType<Builders[K]>>> {
103
+ if (!(builderName in this.builders)) {
104
+ throw new Error(
105
+ `Factory "${
106
+ builderName as string
107
+ }" does not exist. Make sure it is correct and registered in src/test/setup.ts`,
108
+ );
109
+ }
110
+
111
+ const result = await this.builders[builderName](attrs || {}, this, this.db);
112
+
113
+ // For Kysely, we expect the builder to return an object with table and data properties
114
+ // or to handle the insertion itself and return the inserted record
115
+ if (
116
+ result &&
117
+ typeof result === 'object' &&
118
+ 'table' in result &&
119
+ 'data' in result
120
+ ) {
121
+ // If the builder returns {table: string, data: object}, we insert it
122
+ const inserted = await this.db
123
+ .insertInto(result.table)
124
+ .values(result.data)
125
+ .returningAll()
126
+ .executeTakeFirst();
127
+
128
+ return inserted as any;
129
+ }
130
+
131
+ // Otherwise, assume the builder handled the insertion itself
132
+ return result;
133
+ }
134
+
135
+ // Method overloads for better type inference
136
+ async insertMany<K extends keyof Builders>(
137
+ count: number,
138
+ builderName: K,
139
+ attrs?: Parameters<Builders[K]>[0],
140
+ ): Promise<Awaited<ReturnType<Builders[K]>>[]>;
141
+ async insertMany<K extends keyof Builders>(
142
+ count: number,
143
+ builderName: K,
144
+ attrs: (idx: number) => Parameters<Builders[K]>[0],
145
+ ): Promise<Awaited<ReturnType<Builders[K]>>[]>;
146
+ async insertMany<K extends keyof Builders>(
147
+ count: number,
148
+ builderName: K,
149
+ attrs?: any,
150
+ ): Promise<Awaited<ReturnType<Builders[K]>>[]> {
151
+ if (!(builderName in this.builders)) {
152
+ throw new Error(
153
+ `Builder "${
154
+ builderName as string
155
+ }" is not registered in this factory. Make sure it is correct and registered in src/test/setup.ts`,
156
+ );
157
+ }
158
+
159
+ const promises: Promise<any>[] = [];
160
+
161
+ for (let i = 0; i < count; i++) {
162
+ const newAttrs = typeof attrs === 'function' ? attrs(i) : attrs;
163
+ promises.push(this.insert(builderName, newAttrs));
164
+ }
165
+
166
+ return Promise.all(promises);
167
+ }
168
+
169
+ seed<K extends keyof Seeds>(
170
+ seedName: K,
171
+ attrs?: Parameters<Seeds[K]>[0],
172
+ ): ReturnType<Seeds[K]> {
173
+ if (!(seedName in this.seeds)) {
174
+ throw new Error(
175
+ `Seed "${
176
+ seedName as string
177
+ }" is not registered in this factory. Make sure it is correct and registered in src/test/setup.ts`,
178
+ );
179
+ }
180
+
181
+ return this.seeds[seedName](attrs || {}, this, this.db);
182
+ }
183
+ }
File without changes
@@ -0,0 +1,66 @@
1
+ import type { Knex } from 'knex';
2
+ import { Factory, type FactorySeed } from './Factory.ts';
3
+
4
+ export class ObjectionFactory<
5
+ Builders extends Record<string, any>,
6
+ Seeds extends Record<string, any>,
7
+ > extends Factory<Builders, Seeds> {
8
+ static createSeed<Seed extends FactorySeed>(seedFn: Seed): Seed {
9
+ return Factory.createSeed(seedFn);
10
+ }
11
+
12
+ constructor(
13
+ private builders: Builders,
14
+ private seeds: Seeds,
15
+ private db: Knex,
16
+ ) {
17
+ super();
18
+ }
19
+
20
+ insert(factory, attrs = {}) {
21
+ if (!(factory in this.builders)) {
22
+ throw new Error(
23
+ `Factory "${
24
+ factory as string
25
+ }" does not exist. Make sure it is correct and registered in src/test/setup.ts`,
26
+ );
27
+ }
28
+
29
+ return this.builders[factory](attrs, {}, this.db).then((record: any) => {
30
+ return record.$query(this.db).insertGraph(record).execute();
31
+ }) as any;
32
+ }
33
+ insertMany(count, builderName, attrs = {}) {
34
+ if (!(builderName in this.builders)) {
35
+ throw new Error(
36
+ `Builder "${
37
+ builderName as string
38
+ }" is not registered in this factory. Make sure it is correct and registered in src/test/setup.ts`,
39
+ );
40
+ }
41
+
42
+ const records: any[] = [];
43
+ for (let i = 0; i < count; i++) {
44
+ const newAttrs = typeof attrs === 'function' ? (attrs as any)(i) : attrs;
45
+
46
+ records.push(
47
+ this.builders[builderName](newAttrs, {}, this.db).then((record: any) =>
48
+ record.$query(this.db).insertGraph(record).execute(),
49
+ ),
50
+ );
51
+ }
52
+
53
+ return Promise.all(records);
54
+ }
55
+ seed(seedName, attrs = {}) {
56
+ if (!(seedName in this.seeds)) {
57
+ throw new Error(
58
+ `Seed "${
59
+ seedName as string
60
+ }" is not registered in this factory. Make sure it is correct and registered in src/test/setup.ts`,
61
+ );
62
+ }
63
+
64
+ return this.seeds[seedName](attrs, this, this.db);
65
+ }
66
+ }
@@ -0,0 +1,33 @@
1
+ import { type Kysely, type MigrationProvider, Migrator } from 'kysely';
2
+ import { PostgresMigrator } from './PostgresMigrator';
3
+
4
+ const logger = console;
5
+
6
+ export class PostgresKyselyMigrator extends PostgresMigrator {
7
+ constructor(
8
+ private options: {
9
+ uri: string;
10
+ db: Kysely<any>;
11
+ provider: MigrationProvider;
12
+ },
13
+ ) {
14
+ super(options.uri);
15
+ }
16
+
17
+ async migrate(): Promise<void> {
18
+ const migrator = new Migrator({
19
+ db: this.options.db,
20
+ provider: this.options.provider,
21
+ });
22
+ const migrations = await migrator.migrateToLatest();
23
+
24
+ if (migrations.error) {
25
+ logger.error(migrations.error, `Failed to apply migrations`);
26
+ throw migrations.error;
27
+ }
28
+
29
+ await this.options.db.destroy();
30
+
31
+ logger.log(`Applied ${migrations.results?.length} migrations successfully`);
32
+ }
33
+ }
@@ -0,0 +1,76 @@
1
+ import { Client } from 'pg';
2
+
3
+ async function setupClient(uri: string) {
4
+ const url = new URL(uri);
5
+
6
+ const db = new Client({
7
+ user: url.username,
8
+ password: url.password,
9
+ host: url.hostname,
10
+ port: parseInt(url.port),
11
+ database: 'postgres',
12
+ });
13
+
14
+ let database = url.pathname.slice(1);
15
+ if (database.includes('?')) {
16
+ database = database.substring(0, database.indexOf('?'));
17
+ }
18
+ return { database, db };
19
+ }
20
+
21
+ const logger = console;
22
+
23
+ export abstract class PostgresMigrator {
24
+ constructor(private uri: string) {}
25
+
26
+ abstract migrate(): Promise<void>;
27
+
28
+ private static async create(
29
+ uri: string,
30
+ ): Promise<{ alreadyExisted: boolean }> {
31
+ const { database, db } = await setupClient(uri);
32
+ try {
33
+ await db.connect();
34
+ const result = await db.query(
35
+ `SELECT * FROM pg_catalog.pg_database WHERE datname = '${database}'`,
36
+ );
37
+
38
+ if (result.rowCount === 0) {
39
+ await db.query(`CREATE DATABASE "${database}"`);
40
+ }
41
+
42
+ return {
43
+ alreadyExisted: result.rowCount ? result.rowCount > 0 : false,
44
+ };
45
+ } finally {
46
+ await db.end();
47
+ }
48
+ }
49
+
50
+ private static async drop(uri: string): Promise<void> {
51
+ const { database, db } = await setupClient(uri);
52
+ try {
53
+ await db.connect();
54
+ await db.query(`DROP DATABASE "${database}"`);
55
+ } finally {
56
+ await db.end();
57
+ }
58
+ }
59
+
60
+ async start() {
61
+ const { database, db } = await setupClient(this.uri);
62
+ try {
63
+ await PostgresMigrator.create(this.uri);
64
+ // Implement migration logic here
65
+ await this.migrate();
66
+ logger.log(`Migrating database: ${database}`);
67
+ // Example: await db.query('CREATE TABLE example (id SERIAL PRIMARY KEY)');
68
+ } finally {
69
+ await db.end();
70
+ }
71
+
72
+ return async () => {
73
+ await PostgresMigrator.drop(this.uri);
74
+ };
75
+ }
76
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ CamelCasePlugin,
3
+ type ControlledTransaction,
4
+ Kysely,
5
+ PostgresDialect,
6
+ } from 'kysely';
7
+ import pg from 'pg';
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeAll,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ it,
16
+ } from 'vitest';
17
+ import { TEST_DATABASE_CONFIG } from '../../test/globalSetup';
18
+ import { KyselyFactory } from '../KyselyFactory';
19
+
20
+ describe('KyselyFactory', () => {
21
+ interface Database {
22
+ users: {
23
+ id: number;
24
+ name: string;
25
+ email: string;
26
+ createdAt: Date;
27
+ };
28
+ posts: {
29
+ id: number;
30
+ title: string;
31
+ content: string;
32
+ userId: number;
33
+ createdAt: Date;
34
+ };
35
+ }
36
+
37
+ let db: Kysely<Database>;
38
+ let trx: ControlledTransaction<Database, []>;
39
+ beforeAll(async () => {
40
+ db = new Kysely({
41
+ dialect: new PostgresDialect({
42
+ pool: new pg.Pool(TEST_DATABASE_CONFIG),
43
+ }),
44
+ plugins: [new CamelCasePlugin()],
45
+ });
46
+ });
47
+
48
+ beforeEach(async () => {
49
+ trx = await db.startTransaction().execute();
50
+ });
51
+ afterEach(async () => {
52
+ await trx.rollback().execute();
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await db.destroy();
57
+ });
58
+ it('KyselyFactory.insert', async () => {
59
+ const userBuilder = KyselyFactory.createBuilder<Database, 'users'>({
60
+ table: 'users',
61
+ defaults: async (attrs) => ({
62
+ name: 'John Doe',
63
+ }),
64
+ });
65
+
66
+ const builders = {
67
+ user: userBuilder,
68
+ };
69
+
70
+ const factory = new KyselyFactory<Database, typeof builders, {}>(
71
+ builders,
72
+ {},
73
+ trx,
74
+ );
75
+
76
+ const user = await factory.insert('user', {
77
+ email: `user${Date.now()}@example.com`,
78
+ createdAt: new Date(),
79
+ });
80
+
81
+ expect(user).toBeDefined();
82
+ });
83
+ });
package/src/example.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { KyselyFactory } from './KyselyFactory';
2
+
3
+ interface Database {
4
+ users: {
5
+ id: number;
6
+ name: string;
7
+ email: string;
8
+ createdAt: Date;
9
+ };
10
+ posts: {
11
+ id: number;
12
+ title: string;
13
+ content: string;
14
+ userId: number;
15
+ createdAt: Date;
16
+ };
17
+ }
18
+
19
+ const userBuilder = KyselyFactory.createBuilder<Database, 'users'>({
20
+ table: 'users',
21
+ defaults: async (attrs) => ({
22
+ name: 'John Doe',
23
+ email: `user${Date.now()}@example.com`,
24
+ createdAt: new Date(),
25
+ }),
26
+ });
27
+
28
+ const builders = {
29
+ user: userBuilder,
30
+ };
31
+
32
+ export type Builders = typeof builders;
33
+ export type Seeds = Record<string, any>;
34
+
35
+ const factory = new KyselyFactory<Database, Builders, Seeds>(
36
+ builders,
37
+ {},
38
+ {} as any,
39
+ );
40
+
41
+ factory.insert('user', {
42
+ name: 'Jane Doe',
43
+ email: `user${Date.now()}@example.com`,
44
+ createdAt: new Date(),
45
+ });
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import pg from 'pg';
4
+
5
+ import {
6
+ CamelCasePlugin,
7
+ FileMigrationProvider,
8
+ Kysely,
9
+ PostgresDialect,
10
+ } from 'kysely';
11
+
12
+ import { PostgresKyselyMigrator } from '../src/PostgresKyselyMigrator';
13
+
14
+ const TEST_DATABASE_NAME = 'geekmidas_test';
15
+
16
+ const logger = console;
17
+
18
+ export const TEST_DATABASE_CONFIG = {
19
+ host: 'localhost',
20
+ port: 5432,
21
+ user: 'geekmidas',
22
+ password: 'geekmidas',
23
+ database: TEST_DATABASE_NAME,
24
+ };
25
+
26
+ // password: get('Database.password').string(),
27
+ // user: get('Database.username').string(),
28
+ // database: get('Database.database').string(),
29
+ // host: get('Database.host').string(),
30
+ // port: get('Database.port').number().default(5432),
31
+
32
+ export default async function globalSetup() {
33
+ const uri = `postgres://${TEST_DATABASE_CONFIG.user}:${TEST_DATABASE_CONFIG.password}@${TEST_DATABASE_CONFIG.host}:${TEST_DATABASE_CONFIG.port}/${TEST_DATABASE_CONFIG.database}`;
34
+
35
+ const migrationFolder = path.resolve(__dirname, './migrations');
36
+
37
+ const migrationProcessor = new PostgresKyselyMigrator({
38
+ uri,
39
+ db: new Kysely({
40
+ dialect: new PostgresDialect({
41
+ pool: new pg.Pool(TEST_DATABASE_CONFIG),
42
+ }),
43
+ plugins: [new CamelCasePlugin()],
44
+ }),
45
+ provider: new FileMigrationProvider({
46
+ fs,
47
+ path,
48
+ migrationFolder,
49
+ }),
50
+ });
51
+
52
+ return migrationProcessor.start();
53
+ }
@@ -0,0 +1,22 @@
1
+ import { type Kysely, sql } from 'kysely';
2
+
3
+ // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
4
+ export async function up(db: Kysely<any>): Promise<void> {
5
+ await db.schema
6
+ .createTable('users')
7
+ .addColumn('id', 'bigserial', (col) => col.primaryKey())
8
+ .addColumn('email', 'varchar', (col) => col.notNull().unique())
9
+ .addColumn('name', 'varchar', (col) => col.notNull())
10
+ .addColumn('created_at', 'timestamp', (col) =>
11
+ col.defaultTo(sql`now()`).notNull(),
12
+ )
13
+ .addColumn('updated_at', 'timestamp', (col) =>
14
+ col.defaultTo(sql`now()`).notNull(),
15
+ )
16
+ .execute();
17
+ }
18
+
19
+ // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
20
+ export async function down(db: Kysely<any>): Promise<void> {
21
+ await db.schema.dropTable('users').execute();
22
+ }
@@ -0,0 +1,8 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ export default defineProject({
4
+ test: {
5
+ globalSetup: ['test/globalSetup.ts'],
6
+ pool: 'forks',
7
+ },
8
+ });