From 5ab0dee907f6583c901cb25e269a27608c2849e0 Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 12:13:30 +0200 Subject: [PATCH 1/9] enhance TypeormFakeEntityService to support composite primary keys and improve key validation --- sequelize/migrations/0000000-initial.js | 2 + src/typeorm-fake-entity.service.ts | 117 ++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/sequelize/migrations/0000000-initial.js b/sequelize/migrations/0000000-initial.js index 243fd2e..4beb061 100644 --- a/sequelize/migrations/0000000-initial.js +++ b/sequelize/migrations/0000000-initial.js @@ -167,6 +167,7 @@ const migrationCommands = [ onDelete: 'CASCADE', onUpdate: 'CASCADE', allowNull: false, + primaryKey: true, type: Sequelize.INTEGER, }, follower_id: { @@ -177,6 +178,7 @@ const migrationCommands = [ onDelete: 'CASCADE', onUpdate: 'CASCADE', allowNull: false, + primaryKey: true, type: Sequelize.INTEGER, }, created_at: { diff --git a/src/typeorm-fake-entity.service.ts b/src/typeorm-fake-entity.service.ts index 744af98..d1eed8f 100644 --- a/src/typeorm-fake-entity.service.ts +++ b/src/typeorm-fake-entity.service.ts @@ -1,4 +1,4 @@ -import {DeepPartial, EntityManager, FindOneOptions, Repository} from "typeorm"; +import {DeepPartial, EntityManager, FindOneOptions, Repository, In, FindOptionsWhere} from "typeorm"; import {FakeEntityCoreService, MultipleKeyRelations, SingleKeyRelation} from "./fake-entity-core.service"; @@ -6,7 +6,7 @@ import {FakeEntityCoreService, MultipleKeyRelations, SingleKeyRelation} from "./ export class TypeormFakeEntityService extends FakeEntityCoreService{ public entityIds = []; - public idFieldName = 'id'; + public idFieldNames: string[] = []; protected nestedEntities: { service: TypeormFakeEntityService, @@ -24,6 +24,88 @@ export class TypeormFakeEntityService extends FakeEntityCoreService) { super(); + this.detectPrimaryKeys(); + } + + /** + * Automatically detect primary keys from TypeORM entity metadata + */ + private detectPrimaryKeys(): void { + if (this.idFieldNames.length === 0) { + const primaryColumns = this.repository.metadata.primaryColumns; + this.idFieldNames = primaryColumns.map(column => column.propertyName); + this.validatePrimaryKeys(); + } + } + + /** + * Validate that primary keys were detected properly + */ + private validatePrimaryKeys(): void { + if (this.idFieldNames.length === 0) { + throw new Error(`No primary keys detected for entity ${this.repository.metadata.name}. Please ensure the entity has @PrimaryColumn or @PrimaryGeneratedColumn decorators.`); + } + } + + /** + * Get primary key field names + */ + public getIdFieldNames(): string[] { + return this.idFieldNames; + } + + /** + * Check if entity has composite primary key + */ + public hasCompositeId(): boolean { + return this.getIdFieldNames().length > 1; + } + + /** + * Get TypeORM primary column metadata + */ + public getPrimaryColumns() { + return this.repository.metadata.primaryColumns; + } + + /** + * Extract primary key values from an entity object + */ + protected pickKeysFromObject(obj: any): Record { + const result = {}; + for (const key of this.getIdFieldNames()) { + const value = obj[key]; + if (value === undefined || value === null) { + throw new Error(`Primary key field "${key}" is empty or null in entity ${this.repository.metadata.name}`); + } + result[key] = value; + } + return result; + } + + /** + * Build where conditions for composite primary keys + */ + protected buildCompositeKeyWhere(keyValues: Record): FindOptionsWhere { + const where = {} as FindOptionsWhere; + for (const [key, value] of Object.entries(keyValues)) { + if (!this.getIdFieldNames().includes(key)) { + throw new Error(`Invalid primary key field "${key}" for entity ${this.repository.metadata.name}`); + } + where[key] = value; + } + return where; + } + + /** + * Find entity by composite primary key + */ + public async findByCompositeKey(keyValues: Record, transaction?: EntityManager): Promise { + return this.withTransaction(async (tx) => { + const where = this.buildCompositeKeyWhere(keyValues); + const repo = tx ? tx.getRepository(this.repository.target) : this.repository; + return repo.findOne({ where }); + }, transaction); } /** @@ -243,10 +325,15 @@ export class TypeormFakeEntityService extends FakeEntityCoreService extends FakeEntityCoreService { return this.withTransaction(async (tx) => { - // Use tx if available, otherwise use this.repository const repo = tx ? tx.getRepository(this.repository.target) : this.repository; - const res = await repo.delete(ids); - return res.affected || 0; + + if (this.hasCompositeId()) { + // For composite keys, ids should be an array of key objects + const whereConditions = ids.map(id => this.buildCompositeKeyWhere(id)); + let totalAffected = 0; + for (const where of whereConditions) { + const res = await repo.delete(where); + totalAffected += res.affected || 0; + } + return totalAffected; + } else { + // For single keys, use In operator for bulk delete + const idField = this.getIdFieldNames()[0]; + const where = { [idField]: In(ids) } as any; + const res = await repo.delete(where); + return res.affected || 0; + } }, transaction).then((deletionResult) => { // Remove deleted entity IDs from the entityIds array this.entityIds = []; From 04e916299df66454ea08b2ee2011d221a926b75b Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 13:46:33 +0200 Subject: [PATCH 2/9] add FakeFollowerService and Follower entity with composite key support --- tests/typeorm-basics.int-spec.ts | 89 ++++ tests/typeorm-composite-keys.int-spec.ts | 450 ++++++++++++++++++ .../fake-follower.service.ts | 13 + tests/typeorm-models/follower.entity.ts | 25 + tests/typeorm-primary-key-detection.spec.ts | 227 +++++++++ 5 files changed, 804 insertions(+) create mode 100644 tests/typeorm-composite-keys.int-spec.ts create mode 100644 tests/typeorm-factories/fake-follower.service.ts create mode 100644 tests/typeorm-models/follower.entity.ts create mode 100644 tests/typeorm-primary-key-detection.spec.ts diff --git a/tests/typeorm-basics.int-spec.ts b/tests/typeorm-basics.int-spec.ts index 98022b5..c5ec151 100644 --- a/tests/typeorm-basics.int-spec.ts +++ b/tests/typeorm-basics.int-spec.ts @@ -48,6 +48,23 @@ describe('Test TypeormFakeEntityService can create and cleanup DB entities', () expect(user.lastName).toBeDefined(); }); + it('should automatically detect primary key for User entity', () => { + expect(fakeUserService.getIdFieldNames()).toEqual(['id']); + expect(fakeUserService.hasCompositeId()).toBe(false); + + const primaryColumns = fakeUserService.getPrimaryColumns(); + expect(primaryColumns).toHaveLength(1); + expect(primaryColumns[0].propertyName).toBe('id'); + }); + + it('should extract single primary key ID correctly', async () => { + const user = await fakeUserService.create(); + const extractedId = fakeUserService.getId(user); + + expect(extractedId).toBe(user.id); + expect(typeof extractedId).toBe('number'); + }); + it('should create N users', async () => { const users = await fakeUserService.createMany(3); expect(users).toBeDefined(); @@ -335,6 +352,74 @@ describe('Test TypeormFakeEntityService can create and cleanup DB entities', () expect(users[2].lastName).toBe('Order2'); }); + it('should delete entities using automatic primary key detection', async () => { + const users = await fakeUserService.createMany(3); + expect(users).toHaveLength(3); + + // Extract IDs using automatic detection + const userIds = users.map(user => fakeUserService.getId(user)); + expect(userIds).toHaveLength(3); + userIds.forEach(id => { + expect(typeof id).toBe('number'); + expect(id).toBeGreaterThan(0); + }); + + // Delete using detected IDs + const deletedCount = await fakeUserService.delete(userIds); + expect(deletedCount).toBe(3); + + // Verify entities are deleted + for (const id of userIds) { + const found = await fakeUserService.repository.findOne({ where: { id } }); + expect(found).toBeNull(); + } + }); + + it('should handle mixed primary key scenarios across different entities', async () => { + // Test User entity (single primary key) + const user = await fakeUserService.create(); + expect(fakeUserService.getIdFieldNames()).toEqual(['id']); + expect(fakeUserService.hasCompositeId()).toBe(false); + expect(fakeUserService.getId(user)).toBe(user.id); + + // Test Post entity (single primary key) + const post = await fakePostService.create({ userId: user.id, message: 'Test post' }); + expect(fakePostService.getIdFieldNames()).toEqual(['id']); + expect(fakePostService.hasCompositeId()).toBe(false); + expect(fakePostService.getId(post)).toBe(post.id); + + // Test Comment entity (single primary key) + const comment = await fakeCommentService.create({ + userId: user.id, + postId: post.id, + message: 'Test comment' + }); + expect(fakeCommentService.getIdFieldNames()).toEqual(['id']); + expect(fakeCommentService.hasCompositeId()).toBe(false); + expect(fakeCommentService.getId(comment)).toBe(comment.id); + + // Cleanup + await fakeCommentService.cleanup(); + await fakePostService.cleanup(); + }); + + it('should validate primary key detection consistency', async () => { + // Create multiple users and verify consistent ID extraction + const users = await fakeUserService.createMany(5); + + for (const user of users) { + const extractedId = fakeUserService.getId(user); + expect(extractedId).toBe(user.id); + expect(typeof extractedId).toBe('number'); + expect(extractedId).toBeGreaterThan(0); + } + + // Verify all IDs are unique + const allIds = users.map(user => fakeUserService.getId(user)); + const uniqueIds = [...new Set(allIds)]; + expect(uniqueIds).toHaveLength(allIds.length); + }); + it('should clone service and produce empty state', async () => { // Arrange: create a user in the original service const user = await fakeUserService.create(); @@ -348,6 +433,10 @@ describe('Test TypeormFakeEntityService can create and cleanup DB entities', () expect(clonedService).not.toBe(fakeUserService); expect(clonedService.repository).toBe(fakeUserService.repository); expect(clonedService.entityIds.length).toBe(0); + + // Assert: cloned service should have same primary key detection + expect(clonedService.getIdFieldNames()).toEqual(fakeUserService.getIdFieldNames()); + expect(clonedService.hasCompositeId()).toBe(fakeUserService.hasCompositeId()); // Creating in the clone should not affect the original const user2 = await clonedService.createMany(3); diff --git a/tests/typeorm-composite-keys.int-spec.ts b/tests/typeorm-composite-keys.int-spec.ts new file mode 100644 index 0000000..6e64b45 --- /dev/null +++ b/tests/typeorm-composite-keys.int-spec.ts @@ -0,0 +1,450 @@ +import {DataSource, Repository} from "typeorm" +import {User} from "./typeorm-models/user.entity"; +import {Role, RoleIds} from "./typeorm-models/role.entity"; +import {Follower} from "./typeorm-models/follower.entity"; +import {FakeUserService} from "./typeorm-factories/fake-user.service"; +import {FakeFollowerService} from "./typeorm-factories/fake-follower.service"; + +const PostgresDataSource = new DataSource({ + host: 'localhost', + port: 54323, + type: 'postgres', + database: 'test-db', + username: 'tester', + password: 'test-pwd', + synchronize: false, + entities: [User, Role, Follower], +}); + +let fakeUserService: FakeUserService; +let fakeFollowerService: FakeFollowerService; +let createdRoleIds: { admin: number; customer: number; manager: number }; + +describe('TypeORM Composite Key Operations', () => { + + beforeAll(async () => { + await PostgresDataSource.initialize(); + + // Create required roles for the tests using direct SQL to handle upserts + try { + await PostgresDataSource.query(` + INSERT INTO roles (id, name) VALUES + (${RoleIds.ADMIN}, 'ADMIN'), + (${RoleIds.CUSTOMER}, 'CUSTOMER'), + (${RoleIds.MANAGER}, 'MANAGER') + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name + `); + } catch (error) { + // Roles might already exist, which is fine + } + + // Get the actual created role IDs + const roleRepo = PostgresDataSource.getRepository(Role); + const roles = await roleRepo.find(); + + // Map the created roles to their actual IDs + createdRoleIds = { + admin: roles.find(r => r.name === 'ADMIN')?.id || 1, + customer: roles.find(r => r.name === 'CUSTOMER')?.id || 2, + manager: roles.find(r => r.name === 'MANAGER')?.id || 3 + }; + + const userRepo = PostgresDataSource.getRepository(User); + const followerRepo = PostgresDataSource.getRepository(Follower); + + fakeUserService = new FakeUserService(userRepo); + fakeFollowerService = new FakeFollowerService(followerRepo); + }); + + afterAll(async () => { + await PostgresDataSource.destroy(); + }); + + afterEach(async () => { + await fakeFollowerService.cleanup(); + await fakeUserService.cleanup(); + }); + + describe('Primary Key Detection', () => { + it('should detect single primary key for User entity', () => { + expect(fakeUserService.getIdFieldNames()).toEqual(['id']); + expect(fakeUserService.hasCompositeId()).toBe(false); + }); + + it('should detect composite primary keys for Follower entity', () => { + expect(fakeFollowerService.getIdFieldNames()).toEqual(['leaderId', 'followerId']); + expect(fakeFollowerService.hasCompositeId()).toBe(true); + }); + + it('should access primary column metadata', () => { + const userPrimaryColumns = fakeUserService.getPrimaryColumns(); + expect(userPrimaryColumns).toHaveLength(1); + expect(userPrimaryColumns[0].propertyName).toBe('id'); + + const followerPrimaryColumns = fakeFollowerService.getPrimaryColumns(); + expect(followerPrimaryColumns).toHaveLength(2); + expect(followerPrimaryColumns.map(col => col.propertyName).sort()).toEqual(['followerId', 'leaderId']); + }); + }); + + describe('Composite Key CRUD Operations', () => { + it('should create and retrieve Follower by composite key', async () => { + // Create users first to satisfy foreign key constraints + const leader = await fakeUserService.create({ + email: 'leader@test.com', + firstName: 'Leader', + lastName: 'User', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser = await fakeUserService.create({ + email: 'follower@test.com', + firstName: 'Follower', + lastName: 'User', + password: 'password', + roleId: createdRoleIds.customer + }); + + const follower = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + expect(follower).toBeDefined(); + expect(follower.leaderId).toBe(leader.id); + expect(follower.followerId).toBe(followerUser.id); + expect(follower.createdAt).toBeInstanceOf(Date); + + // Test getId returns composite key object + const id = fakeFollowerService.getId(follower); + expect(id).toEqual({ leaderId: leader.id, followerId: followerUser.id }); + }); + + it('should create multiple Follower entities with composite keys', async () => { + // Create a leader user + const leader = await fakeUserService.create({ + email: 'leader-multi@test.com', + firstName: 'Leader', + lastName: 'Multi', + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create multiple follower users + const followerUsers = await fakeUserService.createMany(3, { + password: 'password', + roleId: createdRoleIds.customer + }); + + const followers = await fakeFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))).createMany(3,{ + leaderId: leader.id, + createdAt: new Date() + }); + + expect(followers).toHaveLength(3); + + for (const follower of followers) { + expect(follower.leaderId).toBe(leader.id); + expect(follower.followerId).toBeDefined(); + expect(follower.createdAt).toBeInstanceOf(Date); + + // Each should have a valid composite ID + const id = fakeFollowerService.getId(follower); + expect(id).toHaveProperty('leaderId'); + expect(id).toHaveProperty('followerId'); + } + }); + + it('should find entity by composite key', async () => { + // Create users first + const leader = await fakeUserService.create({ + email: 'search-leader@test.com', + firstName: 'Search', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser = await fakeUserService.create({ + email: 'search-follower@test.com', + firstName: 'Search', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }); + + const created = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + const found = await fakeFollowerService.findByCompositeKey({ + leaderId: leader.id, + followerId: followerUser.id + }); + + expect(found).toBeDefined(); + expect(found.leaderId).toBe(created.leaderId); + expect(found.followerId).toBe(created.followerId); + expect(found.createdAt).toEqual(created.createdAt); + }); + + it('should return undefined when composite key not found', async () => { + const notFound = await fakeFollowerService.findByCompositeKey({ + leaderId: 999999, + followerId: 999999 + }); + + expect(notFound).toBeUndefined(); + }); + + it('should cleanup entities by composite key', async () => { + // Create users first + const leader = await fakeUserService.create({ + email: 'delete-leader@test.com', + firstName: 'Delete', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser1 = await fakeUserService.create({ + email: 'delete-follower1@test.com', + firstName: 'Delete', + lastName: 'Follower1', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser2 = await fakeUserService.create({ + email: 'delete-follower2@test.com', + firstName: 'Delete', + lastName: 'Follower2', + password: 'password', + roleId: createdRoleIds.customer + }); + + const follower1 = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser1.id, + createdAt: new Date() + }); + + const follower2 = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser2.id, + createdAt: new Date() + }); + + expect(fakeFollowerService.entityIds.length).toBe(2); + + // Delete using composite key objects + const deletedCount = await fakeFollowerService.cleanup(); + + expect(deletedCount).toBe(2); + + // Verify they're actually deleted + const found1 = await fakeFollowerService.findByCompositeKey({ leaderId: leader.id, followerId: followerUser1.id }); + const found2 = await fakeFollowerService.findByCompositeKey({ leaderId: leader.id, followerId: followerUser2.id }); + + expect(found1).toBeUndefined(); + expect(found2).toBeUndefined(); + }); + }); + + describe('Mixed Key Type Scenarios', () => { + it('should handle single and composite key entities in same transaction', async () => { + await PostgresDataSource.transaction(async (transactionEntityManager) => { + // Create leader user with single key + const leader = await fakeUserService.create({ + email: 'transaction-leader@example.com', + firstName: 'John', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }, transactionEntityManager); + + // Create follower user + const followerUser = await fakeUserService.create({ + email: 'transaction-follower@example.com', + firstName: 'Jane', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }, transactionEntityManager); + + expect(leader.id).toBeDefined(); + expect(fakeUserService.getId(leader)).toBe(leader.id); + + // Create follower relationship with composite key + const follower = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }, transactionEntityManager); + + expect(follower.leaderId).toBe(leader.id); + expect(follower.followerId).toBe(followerUser.id); + + const compositeId = fakeFollowerService.getId(follower); + expect(compositeId).toEqual({ leaderId: leader.id, followerId: followerUser.id }); + }); + }); + + it('should maintain referential integrity with composite keys', async () => { + // Create two users first + const leader = await fakeUserService.create({ + email: 'integrity-leader@test.com', + firstName: 'Integrity', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.admin + }); + + const followerUser = await fakeUserService.create({ + email: 'integrity-follower@test.com', + firstName: 'Integrity', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create follower relationship that references both users + const follower = await fakeFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + expect(follower.leaderId).toBe(leader.id); + expect(follower.followerId).toBe(followerUser.id); + + // Verify both entities exist and are properly linked + const foundLeader = await fakeUserService.findByCompositeKey({ id: leader.id }); + const foundFollower = await fakeFollowerService.findByCompositeKey({ + leaderId: leader.id, + followerId: followerUser.id + }); + + expect(foundLeader).toBeDefined(); + expect(foundFollower).toBeDefined(); + expect(foundFollower.leaderId).toBe(foundLeader.id); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should throw error for missing composite key fields', async () => { + expect(() => fakeFollowerService.getId({ leaderId: 1 } as Follower)) + .toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + + it('should throw error for invalid composite key field in where clause', () => { + expect(() => fakeFollowerService['buildCompositeKeyWhere']({ leaderId: 1, invalidField: 2 })) + .toThrow('Invalid primary key field "invalidField" for entity Follower'); + }); + + it('should handle null values in composite keys gracefully', () => { + expect(() => fakeFollowerService.getId({ leaderId: 1, followerId: null } as Follower)) + .toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + + it('should handle undefined values in composite keys gracefully', () => { + expect(() => fakeFollowerService.getId({ leaderId: 1 } as Follower)) + .toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + }); + + describe('Performance and Bulk Operations', () => { + it('should efficiently handle bulk composite key operations', async () => { + // Create users for the test + const leader = await fakeUserService.create({ + email: 'bulk-leader@test.com', + firstName: 'Bulk', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUsers = await fakeUserService.createMany(50, { + password: 'password', + roleId: createdRoleIds.customer + }); + + const startTime = Date.now(); + + // Create 50 follower relationships + const followers = await Promise.all( + followerUsers.map(user => + fakeFollowerService.create({ + leaderId: leader.id, + followerId: user.id, + createdAt: new Date() + }) + ) + ); + + const creationTime = Date.now() - startTime; + expect(creationTime).toBeLessThan(5000); // Should complete within 5 seconds + + expect(followers).toHaveLength(50); + + // Verify all have valid composite IDs + for (const follower of followers) { + const id = fakeFollowerService.getId(follower); + expect(id).toHaveProperty('leaderId'); + expect(id).toHaveProperty('followerId'); + expect(typeof id.leaderId).toBe('number'); + expect(typeof id.followerId).toBe('number'); + } + }); + + it('should efficiently delete multiple entities by composite keys', async () => { + // Create users for the test + const leader = await fakeUserService.create({ + email: 'delete-bulk-leader@test.com', + firstName: 'Delete', + lastName: 'BulkLeader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUsers = await fakeUserService.createMany(10, { + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create test data + const followers = await Promise.all( + followerUsers.map(user => + fakeFollowerService.create({ + leaderId: leader.id, + followerId: user.id, + createdAt: new Date() + }) + ) + ); + + // Extract composite IDs for deletion + const compositeIds = followers.map(f => fakeFollowerService.getId(f)); + + const startTime = Date.now(); + const deletedCount = await fakeFollowerService.delete(compositeIds); + const deletionTime = Date.now() - startTime; + + expect(deletedCount).toBe(10); + expect(deletionTime).toBeLessThan(2000); // Should complete within 2 seconds + + // Verify all are deleted + for (const id of compositeIds) { + const found = await fakeFollowerService.findByCompositeKey(id); + expect(found).toBeUndefined(); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/typeorm-factories/fake-follower.service.ts b/tests/typeorm-factories/fake-follower.service.ts new file mode 100644 index 0000000..51b3345 --- /dev/null +++ b/tests/typeorm-factories/fake-follower.service.ts @@ -0,0 +1,13 @@ +import {TypeormFakeEntityService} from "../../src"; +import {Follower} from "../typeorm-models/follower.entity"; + +export class FakeFollowerService extends TypeormFakeEntityService { + + protected setFakeFields(): Partial { + return { + // Don't set leaderId and followerId by default - they should be provided by tests + // or through parent/nested relationships + createdAt: new Date() + }; + } +} \ No newline at end of file diff --git a/tests/typeorm-models/follower.entity.ts b/tests/typeorm-models/follower.entity.ts new file mode 100644 index 0000000..5be2d74 --- /dev/null +++ b/tests/typeorm-models/follower.entity.ts @@ -0,0 +1,25 @@ +import {Column, Entity, PrimaryColumn} from 'typeorm'; + +@Entity({name: 'leader_followers'}) +export class Follower { + @PrimaryColumn({ + name: 'leader_id', + }) + leaderId: number; + + @PrimaryColumn({ + name: 'follower_id', + }) + followerId: number; + + @Column({ + name: 'created_at', + type: 'timestamp', + default: () => 'CURRENT_TIMESTAMP', + transformer: { + from: (value: Date) => value, + to: (value: Date) => value, + }, + }) + createdAt: Date; +} \ No newline at end of file diff --git a/tests/typeorm-primary-key-detection.spec.ts b/tests/typeorm-primary-key-detection.spec.ts new file mode 100644 index 0000000..0a5d0a9 --- /dev/null +++ b/tests/typeorm-primary-key-detection.spec.ts @@ -0,0 +1,227 @@ +import {TypeormFakeEntityService} from "../src"; +import {User} from "./typeorm-models/user.entity"; +import {Follower} from "./typeorm-models/follower.entity"; +import {Role} from "./typeorm-models/role.entity"; +import {Repository} from "typeorm"; + +// Mock repository for testing +class MockRepository { + constructor(public metadata: any, public target: any) {} + + create = jest.fn(); + save = jest.fn(); + delete = jest.fn(); + findOne = jest.fn(); +} + +describe('TypeORM Primary Key Detection', () => { + + describe('Single Primary Key Detection', () => { + let mockRepository: MockRepository; + let service: TypeormFakeEntityService; + + beforeEach(() => { + mockRepository = new MockRepository({ + name: 'User', + primaryColumns: [ + { propertyName: 'id' } + ] + }, User); + + service = new TypeormFakeEntityService(mockRepository as any); + }); + + it('should detect single @PrimaryGeneratedColumn', () => { + expect(service.getIdFieldNames()).toEqual(['id']); + expect(service.hasCompositeId()).toBe(false); + }); + + it('should extract ID from single key entity', () => { + const entity = { id: 123, email: 'test@example.com' } as User; + expect(service.getId(entity)).toBe(123); + }); + + it('should throw error for missing single primary key', () => { + const entity = { email: 'test@example.com' } as User; + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + }); + + describe('Composite Primary Key Detection', () => { + let mockRepository: MockRepository; + let service: TypeormFakeEntityService; + + beforeEach(() => { + mockRepository = new MockRepository({ + name: 'Follower', + primaryColumns: [ + { propertyName: 'leaderId' }, + { propertyName: 'followerId' } + ] + }, Follower); + + service = new TypeormFakeEntityService(mockRepository as any); + }); + + it('should detect multiple @PrimaryColumn (composite)', () => { + expect(service.getIdFieldNames()).toEqual(['leaderId', 'followerId']); + expect(service.hasCompositeId()).toBe(true); + }); + + it('should extract composite ID from entity', () => { + const entity = { leaderId: 1, followerId: 2, createdAt: new Date() } as Follower; + const id = service.getId(entity); + expect(id).toEqual({ leaderId: 1, followerId: 2 }); + }); + + it('should throw error for incomplete composite key', () => { + const entity = { leaderId: 1, createdAt: new Date() } as Follower; + expect(() => service.getId(entity)).toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + + it('should build composite key where conditions', () => { + const keyValues = { leaderId: 1, followerId: 2 }; + const where = service['buildCompositeKeyWhere'](keyValues); + expect(where).toEqual({ leaderId: 1, followerId: 2 }); + }); + + it('should throw error for invalid composite key field', () => { + const keyValues = { leaderId: 1, invalidField: 2 }; + expect(() => service['buildCompositeKeyWhere'](keyValues)).toThrow('Invalid primary key field "invalidField" for entity Follower'); + }); + }); + + describe('Custom Primary Key Names', () => { + let mockRepository: MockRepository; + let service: TypeormFakeEntityService; + + beforeEach(() => { + mockRepository = new MockRepository({ + name: 'CustomEntity', + primaryColumns: [ + { propertyName: 'customId' } + ] + }, Object); + + service = new TypeormFakeEntityService(mockRepository as any); + }); + + it('should detect custom column names', () => { + expect(service.getIdFieldNames()).toEqual(['customId']); + }); + + it('should extract custom primary key', () => { + const entity = { customId: 'abc123', name: 'test' }; + expect(service.getId(entity)).toBe('abc123'); + }); + }); + + describe('Error Handling', () => { + let mockRepository: MockRepository; + + it('should throw error for entities without primary keys', () => { + mockRepository = new MockRepository({ + name: 'NoPrimaryKeyEntity', + primaryColumns: [] + }, Object); + + expect(() => new TypeormFakeEntityService(mockRepository as any)) + .toThrow('No primary keys detected for entity NoPrimaryKeyEntity. Please ensure the entity has @PrimaryColumn or @PrimaryGeneratedColumn decorators.'); + }); + + it('should handle null primary key values', () => { + mockRepository = new MockRepository({ + name: 'User', + primaryColumns: [{ propertyName: 'id' }] + }, User); + + const service = new TypeormFakeEntityService(mockRepository as any); + const entity = { id: null, email: 'test@example.com' } as User; + + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + + it('should handle undefined primary key values', () => { + mockRepository = new MockRepository({ + name: 'User', + primaryColumns: [{ propertyName: 'id' }] + }, User); + + const service = new TypeormFakeEntityService(mockRepository as any); + const entity = { email: 'test@example.com' } as User; + + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + }); + + describe('Metadata Integration', () => { + let mockRepository: MockRepository; + let service: TypeormFakeEntityService; + + beforeEach(() => { + mockRepository = new MockRepository({ + name: 'User', + primaryColumns: [ + { + propertyName: 'id', + type: 'int', + isGenerated: true, + generationStrategy: 'increment' + } + ] + }, User); + + service = new TypeormFakeEntityService(mockRepository as any); + }); + + it('should access primary column metadata', () => { + const primaryColumns = service.getPrimaryColumns(); + expect(primaryColumns).toHaveLength(1); + expect(primaryColumns[0].propertyName).toBe('id'); + expect(primaryColumns[0].isGenerated).toBe(true); + }); + + it('should cache metadata for performance', () => { + // Call multiple times to ensure metadata is cached + const firstCall = service.getIdFieldNames(); + const secondCall = service.getIdFieldNames(); + const thirdCall = service.getIdFieldNames(); + + expect(firstCall).toEqual(secondCall); + expect(secondCall).toEqual(thirdCall); + expect(firstCall).toEqual(['id']); + }); + }); + + describe('Primary Key Validation', () => { + let mockRepository: MockRepository; + let service: TypeormFakeEntityService; + + beforeEach(() => { + mockRepository = new MockRepository({ + name: 'Follower', + primaryColumns: [ + { propertyName: 'leaderId' }, + { propertyName: 'followerId' } + ] + }, Follower); + + service = new TypeormFakeEntityService(mockRepository as any); + }); + + it('should validate composite key completeness in pickKeysFromObject', () => { + const completeEntity = { leaderId: 1, followerId: 2, createdAt: new Date() }; + expect(() => service['pickKeysFromObject'](completeEntity)).not.toThrow(); + + const incompleteEntity = { leaderId: 1, createdAt: new Date() }; + expect(() => service['pickKeysFromObject'](incompleteEntity)) + .toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + + it('should validate all primary key fields are present', () => { + const partialKey = { leaderId: 1 }; + expect(() => service['pickKeysFromObject'](partialKey)) + .toThrow('Primary key field "followerId" is empty or null in entity Follower'); + }); + }); +}); \ No newline at end of file From 96fb2c90a07c1ca84101ecc23409fe228e3ab78f Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 14:24:33 +0200 Subject: [PATCH 3/9] add tests for entityIds management in fake services, including single and composite key scenarios --- src/sequelize-fake-entity.service.ts | 22 ++- tests/sequelize-basics.int-spec.ts | 278 +++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 4 deletions(-) diff --git a/src/sequelize-fake-entity.service.ts b/src/sequelize-fake-entity.service.ts index 941104a..172a534 100644 --- a/src/sequelize-fake-entity.service.ts +++ b/src/sequelize-fake-entity.service.ts @@ -337,11 +337,25 @@ export class SequelizeFakeEntityService extends FakeEntit where, transaction: tx, }); - }, transaction).then((deletionResult) => { + }, transaction).then((affectedCount) => { // Remove deleted entity IDs from the entityIds array - this.entityIds = []; - return deletionResult; - }) + if (this.hasCompositeId()) { + // For composite keys, need deep comparison of objects + this.entityIds = this.entityIds.filter(entityId => { + return !entityIds.some(deletedId => { + // Check if all key fields match + return this.getIdFieldNames().every(field => + entityId[field] === deletedId[field] + ); + }); + }); + } else { + // For single keys, use simple includes + this.entityIds = this.entityIds.filter(id => !entityIds.includes(id)); + } + return affectedCount; + }); + } /** diff --git a/tests/sequelize-basics.int-spec.ts b/tests/sequelize-basics.int-spec.ts index 14b87a0..080f051 100644 --- a/tests/sequelize-basics.int-spec.ts +++ b/tests/sequelize-basics.int-spec.ts @@ -466,4 +466,282 @@ describe('Test SequelizeFakeEntityService can create and cleanup DB entities', ( await fakePostService.cleanup(); await clonedFakePostService.cleanup(); }); + + describe('EntityIds Management', () => { + + describe('Single Primary Key Entity (User)', () => { + beforeEach(async () => { + // Ensure clean state before each test + await fakeUserService.cleanup(); + }); + + it('should track entityIds when creating single entities', async () => { + expect(fakeUserService.entityIds.length).toBe(0); + + const user1 = await fakeUserService.create(); + expect(fakeUserService.entityIds.length).toBe(1); + expect(fakeUserService.entityIds[0]).toBe(user1.id); + + const user2 = await fakeUserService.create(); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).toContain(user1.id); + expect(fakeUserService.entityIds).toContain(user2.id); + }); + + it('should track entityIds when creating multiple entities', async () => { + expect(fakeUserService.entityIds.length).toBe(0); + + const users = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + users.forEach(user => { + expect(fakeUserService.entityIds).toContain(user.id); + }); + }); + + it('should remove specific IDs from entityIds when using delete()', async () => { + const users = await fakeUserService.createMany(5); + expect(fakeUserService.entityIds.length).toBe(5); + + const idsToDelete = [users[1].id, users[3].id]; + const deletedCount = await fakeUserService.delete(idsToDelete); + + expect(deletedCount).toBe(2); + expect(fakeUserService.entityIds.length).toBe(3); + expect(fakeUserService.entityIds).not.toContain(users[1].id); + expect(fakeUserService.entityIds).not.toContain(users[3].id); + expect(fakeUserService.entityIds).toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[2].id); + expect(fakeUserService.entityIds).toContain(users[4].id); + }); + + it('should clear all entityIds when using cleanup()', async () => { + const users = await fakeUserService.createMany(4); + expect(fakeUserService.entityIds.length).toBe(4); + + const deletedCount = await fakeUserService.cleanup(); + + expect(deletedCount).toBe(4); + expect(fakeUserService.entityIds.length).toBe(0); + }); + + it('should handle partial deletions correctly', async () => { + const users = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + // Delete only the first user + const deletedCount = await fakeUserService.delete([users[0].id]); + + expect(deletedCount).toBe(1); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).not.toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[1].id); + expect(fakeUserService.entityIds).toContain(users[2].id); + }); + }); + + describe('Composite Primary Key Entity (LeaderFollower)', () => { + beforeEach(async () => { + // Ensure clean state before each test + await fakeLeaderFollowerService.cleanup(); + await fakeUserService.cleanup(); + }); + + it('should track composite entityIds when creating entities', async () => { + expect(fakeLeaderFollowerService.entityIds.length).toBe(0); + + // Create users for the relationship + const users = await fakeUserService.createMany(3); + + const follower1 = await fakeLeaderFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(1); + const expectedId1 = { leaderId: users[0].id, followerId: users[1].id }; + expect(fakeLeaderFollowerService.entityIds[0]).toEqual(expectedId1); + + const follower2 = await fakeLeaderFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(2); + const expectedId2 = { leaderId: users[1].id, followerId: users[2].id }; + expect(fakeLeaderFollowerService.entityIds).toContainEqual(expectedId1); + expect(fakeLeaderFollowerService.entityIds).toContainEqual(expectedId2); + }); + + it('should remove specific composite IDs from entityIds when using delete()', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(4); + + // Create follower relationships + const follower1 = await fakeLeaderFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + const follower2 = await fakeLeaderFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + const follower3 = await fakeLeaderFollowerService.create({ + leaderId: users[2].id, + followerId: users[3].id + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(3); + + // Delete specific composite entities + const idsToDelete = [ + { leaderId: users[0].id, followerId: users[1].id }, + { leaderId: users[2].id, followerId: users[3].id } + ]; + + const deletedCount = await fakeLeaderFollowerService.delete(idsToDelete); + + expect(deletedCount).toBe(2); + expect(fakeLeaderFollowerService.entityIds.length).toBe(1); + expect(fakeLeaderFollowerService.entityIds).toContainEqual({ + leaderId: users[1].id, + followerId: users[2].id + }); + expect(fakeLeaderFollowerService.entityIds).not.toContainEqual({ + leaderId: users[0].id, + followerId: users[1].id + }); + expect(fakeLeaderFollowerService.entityIds).not.toContainEqual({ + leaderId: users[2].id, + followerId: users[3].id + }); + }); + + it('should clear all composite entityIds when using cleanup()', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(4); + + // Create multiple follower relationships + await fakeLeaderFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + await fakeLeaderFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + await fakeLeaderFollowerService.create({ + leaderId: users[2].id, + followerId: users[3].id + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(3); + + const deletedCount = await fakeLeaderFollowerService.cleanup(); + + expect(deletedCount).toBe(3); + expect(fakeLeaderFollowerService.entityIds.length).toBe(0); + }); + + it('should handle composite key ID matching correctly', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(3); + + const follower1 = await fakeLeaderFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + const follower2 = await fakeLeaderFollowerService.create({ + leaderId: users[1].id, + followerId: users[0].id // Reversed relationship + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(2); + + // Delete only the first relationship + const deletedCount = await fakeLeaderFollowerService.delete([{ + leaderId: users[0].id, + followerId: users[1].id + }]); + + expect(deletedCount).toBe(1); + expect(fakeLeaderFollowerService.entityIds.length).toBe(1); + expect(fakeLeaderFollowerService.entityIds).toContainEqual({ + leaderId: users[1].id, + followerId: users[0].id + }); + expect(fakeLeaderFollowerService.entityIds).not.toContainEqual({ + leaderId: users[0].id, + followerId: users[1].id + }); + }); + }); + + describe('Mixed Scenarios and Edge Cases', () => { + beforeEach(async () => { + await fakeUserService.cleanup(); + await fakeLeaderFollowerService.cleanup(); + }); + + it('should handle create/delete cycles correctly', async () => { + // First cycle + const users1 = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + + // Second cycle + const users2 = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + // Partial deletion + await fakeUserService.delete([users2[0].id]); + expect(fakeUserService.entityIds.length).toBe(2); + + // Final cleanup + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + }); + + it('should not affect entityIds when deletion fails', async () => { + const users = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + + // Try to delete non-existent ID (should not crash) + try { + await fakeUserService.delete([99999]); + } catch (error) { + // Expected behavior may vary - some ORMs silently ignore, others throw + } + + // EntityIds should remain intact + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[1].id); + }); + + it('should maintain separate entityIds between different services', async () => { + const users = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeLeaderFollowerService.entityIds.length).toBe(0); + + const follower = await fakeLeaderFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeLeaderFollowerService.entityIds.length).toBe(1); + + // Clean up one service + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + expect(fakeLeaderFollowerService.entityIds.length).toBe(1); + + // Clean up the other service + await fakeLeaderFollowerService.cleanup(); + expect(fakeLeaderFollowerService.entityIds.length).toBe(0); + }); + }); + }); }); From bcd4b00310a9dc830404da63b458d571f9b7429c Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 14:24:54 +0200 Subject: [PATCH 4/9] add tests for entityIds management in fake services, including composite key scenarios --- src/typeorm-fake-entity.service.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/typeorm-fake-entity.service.ts b/src/typeorm-fake-entity.service.ts index d1eed8f..02ab573 100644 --- a/src/typeorm-fake-entity.service.ts +++ b/src/typeorm-fake-entity.service.ts @@ -104,7 +104,8 @@ export class TypeormFakeEntityService extends FakeEntityCoreService { const where = this.buildCompositeKeyWhere(keyValues); const repo = tx ? tx.getRepository(this.repository.target) : this.repository; - return repo.findOne({ where }); + const result = await repo.findOne({ where }); + return result || undefined; // Convert null to undefined }, transaction); } @@ -375,7 +376,20 @@ export class TypeormFakeEntityService extends FakeEntityCoreService { // Remove deleted entity IDs from the entityIds array - this.entityIds = []; + if (this.hasCompositeId()) { + // For composite keys, need deep comparison of objects + this.entityIds = this.entityIds.filter(entityId => { + return !ids.some(deletedId => { + // Check if all key fields match + return this.getIdFieldNames().every(field => + entityId[field] === deletedId[field] + ); + }); + }); + } else { + // For single keys, use simple includes + this.entityIds = this.entityIds.filter(id => !ids.includes(id)); + } return deletionResult; }); } From d4db5a320fc9ca48f06ee5db8414ae35d6ac6e43 Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 14:25:01 +0200 Subject: [PATCH 5/9] add tests for entityIds management in FakeFollowerService, including composite key scenarios --- tests/typeorm-basics.int-spec.ts | 285 ++++++++++++++++++++++- tests/typeorm-composite-keys.int-spec.ts | 38 ++- 2 files changed, 302 insertions(+), 21 deletions(-) diff --git a/tests/typeorm-basics.int-spec.ts b/tests/typeorm-basics.int-spec.ts index c5ec151..bce88e8 100644 --- a/tests/typeorm-basics.int-spec.ts +++ b/tests/typeorm-basics.int-spec.ts @@ -2,6 +2,8 @@ import {DataSource, Repository} from "typeorm" import {FakeUserService} from "./typeorm-factories/fake-user.service"; import {User} from "./typeorm-models/user.entity"; import {Role, RoleIds} from "./typeorm-models/role.entity"; +import {Follower} from "./typeorm-models/follower.entity"; +import {FakeFollowerService} from "./typeorm-factories/fake-follower.service"; import {FakePostService} from "./typeorm-factories/fake-post.service"; import {Post} from "./typeorm-models/post.entity"; import {Comment} from "./typeorm-models/comment.entity"; @@ -14,10 +16,11 @@ const PostgresDataSource = new DataSource({ username: 'tester', password: 'test-pwd', synchronize: false, - entities: [User, Role, Post, Comment], + entities: [User, Role, Follower, Post, Comment], }); let fakeUserService: FakeUserService; +let fakeFollowerService: FakeFollowerService; let fakePostService: FakePostService; let fakeCommentService: FakeCommentService; @@ -26,7 +29,9 @@ describe('Test TypeormFakeEntityService can create and cleanup DB entities', () beforeAll(async () => { await PostgresDataSource.initialize(); const userRepo = PostgresDataSource.getRepository(User); + const followerRepo = PostgresDataSource.getRepository(Follower); fakeUserService = new FakeUserService(userRepo); + fakeFollowerService = new FakeFollowerService(followerRepo); fakePostService = new FakePostService(PostgresDataSource.getRepository(Post)); fakeCommentService = new FakeCommentService(PostgresDataSource.getRepository(Comment)); }); @@ -444,4 +449,282 @@ describe('Test TypeormFakeEntityService can create and cleanup DB entities', () expect(clonedService.entityIds.length).toBe(3); expect(fakeUserService.entityIds.length).toBe(1); }); + + describe('EntityIds Management', () => { + + describe('Single Primary Key Entity (User)', () => { + beforeEach(async () => { + // Ensure clean state before each test + await fakeUserService.cleanup(); + }); + + it('should track entityIds when creating single entities', async () => { + expect(fakeUserService.entityIds.length).toBe(0); + + const user1 = await fakeUserService.create(); + expect(fakeUserService.entityIds.length).toBe(1); + expect(fakeUserService.entityIds[0]).toBe(user1.id); + + const user2 = await fakeUserService.create(); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).toContain(user1.id); + expect(fakeUserService.entityIds).toContain(user2.id); + }); + + it('should track entityIds when creating multiple entities', async () => { + expect(fakeUserService.entityIds.length).toBe(0); + + const users = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + users.forEach(user => { + expect(fakeUserService.entityIds).toContain(user.id); + }); + }); + + it('should remove specific IDs from entityIds when using delete()', async () => { + const users = await fakeUserService.createMany(5); + expect(fakeUserService.entityIds.length).toBe(5); + + const idsToDelete = [users[1].id, users[3].id]; + const deletedCount = await fakeUserService.delete(idsToDelete); + + expect(deletedCount).toBe(2); + expect(fakeUserService.entityIds.length).toBe(3); + expect(fakeUserService.entityIds).not.toContain(users[1].id); + expect(fakeUserService.entityIds).not.toContain(users[3].id); + expect(fakeUserService.entityIds).toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[2].id); + expect(fakeUserService.entityIds).toContain(users[4].id); + }); + + it('should clear all entityIds when using cleanup()', async () => { + const users = await fakeUserService.createMany(4); + expect(fakeUserService.entityIds.length).toBe(4); + + const deletedCount = await fakeUserService.cleanup(); + + expect(deletedCount).toBe(4); + expect(fakeUserService.entityIds.length).toBe(0); + }); + + it('should handle partial deletions correctly', async () => { + const users = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + // Delete only the first user + const deletedCount = await fakeUserService.delete([users[0].id]); + + expect(deletedCount).toBe(1); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).not.toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[1].id); + expect(fakeUserService.entityIds).toContain(users[2].id); + }); + }); + + describe('Composite Primary Key Entity (Follower)', () => { + beforeEach(async () => { + // Ensure clean state before each test + await fakeFollowerService.cleanup(); + await fakeUserService.cleanup(); + }); + + it('should track composite entityIds when creating entities', async () => { + expect(fakeFollowerService.entityIds.length).toBe(0); + + // Create users for the relationship + const users = await fakeUserService.createMany(3); + + const follower1 = await fakeFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + + expect(fakeFollowerService.entityIds.length).toBe(1); + const expectedId1 = { leaderId: users[0].id, followerId: users[1].id }; + expect(fakeFollowerService.entityIds[0]).toEqual(expectedId1); + + const follower2 = await fakeFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + + expect(fakeFollowerService.entityIds.length).toBe(2); + const expectedId2 = { leaderId: users[1].id, followerId: users[2].id }; + expect(fakeFollowerService.entityIds).toContainEqual(expectedId1); + expect(fakeFollowerService.entityIds).toContainEqual(expectedId2); + }); + + it('should remove specific composite IDs from entityIds when using delete()', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(4); + + // Create follower relationships + const follower1 = await fakeFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + const follower2 = await fakeFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + const follower3 = await fakeFollowerService.create({ + leaderId: users[2].id, + followerId: users[3].id + }); + + expect(fakeFollowerService.entityIds.length).toBe(3); + + // Delete specific composite entities + const idsToDelete = [ + { leaderId: users[0].id, followerId: users[1].id }, + { leaderId: users[2].id, followerId: users[3].id } + ]; + + const deletedCount = await fakeFollowerService.delete(idsToDelete); + + expect(deletedCount).toBe(2); + expect(fakeFollowerService.entityIds.length).toBe(1); + expect(fakeFollowerService.entityIds).toContainEqual({ + leaderId: users[1].id, + followerId: users[2].id + }); + expect(fakeFollowerService.entityIds).not.toContainEqual({ + leaderId: users[0].id, + followerId: users[1].id + }); + expect(fakeFollowerService.entityIds).not.toContainEqual({ + leaderId: users[2].id, + followerId: users[3].id + }); + }); + + it('should clear all composite entityIds when using cleanup()', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(4); + + // Create multiple follower relationships + await fakeFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + await fakeFollowerService.create({ + leaderId: users[1].id, + followerId: users[2].id + }); + await fakeFollowerService.create({ + leaderId: users[2].id, + followerId: users[3].id + }); + + expect(fakeFollowerService.entityIds.length).toBe(3); + + const deletedCount = await fakeFollowerService.cleanup(); + + expect(deletedCount).toBe(3); + expect(fakeFollowerService.entityIds.length).toBe(0); + }); + + it('should handle composite key ID matching correctly', async () => { + // Create users for the relationships + const users = await fakeUserService.createMany(3); + + const follower1 = await fakeFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + const follower2 = await fakeFollowerService.create({ + leaderId: users[1].id, + followerId: users[0].id // Reversed relationship + }); + + expect(fakeFollowerService.entityIds.length).toBe(2); + + // Delete only the first relationship + const deletedCount = await fakeFollowerService.delete([{ + leaderId: users[0].id, + followerId: users[1].id + }]); + + expect(deletedCount).toBe(1); + expect(fakeFollowerService.entityIds.length).toBe(1); + expect(fakeFollowerService.entityIds).toContainEqual({ + leaderId: users[1].id, + followerId: users[0].id + }); + expect(fakeFollowerService.entityIds).not.toContainEqual({ + leaderId: users[0].id, + followerId: users[1].id + }); + }); + }); + + describe('Mixed Scenarios and Edge Cases', () => { + beforeEach(async () => { + await fakeUserService.cleanup(); + await fakeFollowerService.cleanup(); + }); + + it('should handle create/delete cycles correctly', async () => { + // First cycle + const users1 = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + + // Second cycle + const users2 = await fakeUserService.createMany(3); + expect(fakeUserService.entityIds.length).toBe(3); + + // Partial deletion + await fakeUserService.delete([users2[0].id]); + expect(fakeUserService.entityIds.length).toBe(2); + + // Final cleanup + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + }); + + it('should not affect entityIds when deletion fails', async () => { + const users = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + + // Try to delete non-existent ID (should not crash) + try { + await fakeUserService.delete([99999]); + } catch (error) { + // Expected behavior may vary - some ORMs silently ignore, others throw + } + + // EntityIds should remain intact + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeUserService.entityIds).toContain(users[0].id); + expect(fakeUserService.entityIds).toContain(users[1].id); + }); + + it('should maintain separate entityIds between different services', async () => { + const users = await fakeUserService.createMany(2); + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeFollowerService.entityIds.length).toBe(0); + + const follower = await fakeFollowerService.create({ + leaderId: users[0].id, + followerId: users[1].id + }); + + expect(fakeUserService.entityIds.length).toBe(2); + expect(fakeFollowerService.entityIds.length).toBe(1); + + // Clean up one service + await fakeUserService.cleanup(); + expect(fakeUserService.entityIds.length).toBe(0); + expect(fakeFollowerService.entityIds.length).toBe(1); + + // Clean up the other service + await fakeFollowerService.cleanup(); + expect(fakeFollowerService.entityIds.length).toBe(0); + }); + }); + }); }); diff --git a/tests/typeorm-composite-keys.int-spec.ts b/tests/typeorm-composite-keys.int-spec.ts index 6e64b45..170b80c 100644 --- a/tests/typeorm-composite-keys.int-spec.ts +++ b/tests/typeorm-composite-keys.int-spec.ts @@ -378,16 +378,15 @@ describe('TypeORM Composite Key Operations', () => { const startTime = Date.now(); - // Create 50 follower relationships - const followers = await Promise.all( - followerUsers.map(user => - fakeFollowerService.create({ - leaderId: leader.id, - followerId: user.id, - createdAt: new Date() - }) - ) - ); + // Create 50 follower relationships using addStates pattern + const followers = await fakeFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))) + .createMany(50, { + leaderId: leader.id, + createdAt: new Date() + }); const creationTime = Date.now() - startTime; expect(creationTime).toBeLessThan(5000); // Should complete within 5 seconds @@ -419,16 +418,15 @@ describe('TypeORM Composite Key Operations', () => { roleId: createdRoleIds.customer }); - // Create test data - const followers = await Promise.all( - followerUsers.map(user => - fakeFollowerService.create({ - leaderId: leader.id, - followerId: user.id, - createdAt: new Date() - }) - ) - ); + // Create test data using addStates pattern + const followers = await fakeFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))) + .createMany(10, { + leaderId: leader.id, + createdAt: new Date() + }); // Extract composite IDs for deletion const compositeIds = followers.map(f => fakeFollowerService.getId(f)); From 30d01804311d09cccb7ee9632f20ec4ccc911bea Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 14:30:04 +0200 Subject: [PATCH 6/9] update TypeScript configuration to change root directory and test regex pattern --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 15f34ad..6c22194 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "json", "ts" ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", + "rootDir": ".", + "testRegex": "tests/.*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, From 23fe930e192069c651d8798c8eeda17df1f7bf1d Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 14:55:47 +0200 Subject: [PATCH 7/9] add primary key detection tests for single and composite keys in SequelizeFakeEntityService --- src/sequelize-fake-entity.service.ts | 4 +- tests/sequelize-primary-key-detection.spec.ts | 304 ++++++++++++++++++ 2 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 tests/sequelize-primary-key-detection.spec.ts diff --git a/src/sequelize-fake-entity.service.ts b/src/sequelize-fake-entity.service.ts index 172a534..0a852cf 100644 --- a/src/sequelize-fake-entity.service.ts +++ b/src/sequelize-fake-entity.service.ts @@ -200,8 +200,8 @@ export class SequelizeFakeEntityService extends FakeEntit } const idFieldName = this.getIdFieldNames()[0]; const idValue = e[idFieldName]; - if (idValue === undefined) { - throw new Error(`Id field "${idFieldName}" is empty`) + if (idValue === undefined || idValue === null) { + throw new Error(`Primary key field "${idFieldName}" is empty or null in entity ${this.repository.modelName}`); } return e[this.getIdFieldNames()[0]]; } diff --git a/tests/sequelize-primary-key-detection.spec.ts b/tests/sequelize-primary-key-detection.spec.ts new file mode 100644 index 0000000..a68ec8c --- /dev/null +++ b/tests/sequelize-primary-key-detection.spec.ts @@ -0,0 +1,304 @@ +import {SequelizeFakeEntityService} from "../src"; +import {User} from "./sequelize-models/user.entity"; +import {LeaderFollower} from "./sequelize-models/leader-follower.entity"; + +// Mock repository for testing Sequelize primary key detection +class MockSequelizeRepository { + constructor(public primaryKeyAttributes: string[], public modelName: string = 'TestModel') {} + + // Mock Sequelize methods + create = jest.fn(); + bulkCreate = jest.fn(); + destroy = jest.fn(); + findByPk = jest.fn(); +} + +describe('Sequelize Primary Key Detection', () => { + + describe('Single Primary Key Detection', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['id'], 'User'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should detect single @Column({ primaryKey: true })', () => { + expect(service.getIdFieldNames()).toEqual(['id']); + expect(service.hasCompositeId()).toBe(false); + }); + + it('should extract ID from single key entity', () => { + const entity = { id: 123, email: 'test@example.com' } as User; + expect(service.getId(entity)).toBe(123); + }); + + it('should throw error for missing single primary key', () => { + const entity = { email: 'test@example.com' } as User; + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + + it('should throw error for undefined single primary key', () => { + const entity = { id: undefined, email: 'test@example.com' } as User; + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + }); + + describe('Composite Primary Key Detection', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['leaderId', 'followerId'], 'LeaderFollower'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should detect multiple @Column({ primaryKey: true }) (composite)', () => { + expect(service.getIdFieldNames()).toEqual(['leaderId', 'followerId']); + expect(service.hasCompositeId()).toBe(true); + }); + + it('should extract composite ID from entity', () => { + const entity = { leaderId: 1, followerId: 2, createdAt: new Date() } as LeaderFollower; + const id = service.getId(entity); + expect(id).toEqual({ leaderId: 1, followerId: 2 }); + }); + + it('should throw error for incomplete composite key', () => { + const entity = { leaderId: 1, createdAt: new Date() } as LeaderFollower; + expect(() => service.getId(entity)).toThrow('Id field "followerId" is empty'); + }); + + it('should extract composite key using pickKeysFromObject', () => { + const entity = { leaderId: 1, followerId: 2, createdAt: new Date() }; + const keys = service['pickKeysFromObject'](entity); + expect(keys).toEqual({ leaderId: 1, followerId: 2 }); + }); + + it('should throw error for missing composite key field in pickKeysFromObject', () => { + const entity = { leaderId: 1, createdAt: new Date() }; + expect(() => service['pickKeysFromObject'](entity)) + .toThrow('Id field "followerId" is empty'); + }); + }); + + describe('Custom Primary Key Names', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['customId'], 'CustomEntity'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should detect custom column names', () => { + expect(service.getIdFieldNames()).toEqual(['customId']); + expect(service.hasCompositeId()).toBe(false); + }); + + it('should extract custom primary key', () => { + const entity = { customId: 'abc123', name: 'test' }; + expect(service.getId(entity)).toBe('abc123'); + }); + + it('should handle string-based custom primary keys', () => { + const entity = { customId: 'uuid-1234-5678', data: 'some data' }; + expect(service.getId(entity)).toBe('uuid-1234-5678'); + }); + }); + + describe('Multiple Custom Primary Keys', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['tenantId', 'entityId'], 'MultiTenantEntity'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should detect multiple custom primary keys', () => { + expect(service.getIdFieldNames()).toEqual(['tenantId', 'entityId']); + expect(service.hasCompositeId()).toBe(true); + }); + + it('should extract multiple custom primary keys', () => { + const entity = { tenantId: 'tenant1', entityId: 42, data: 'test data' }; + const id = service.getId(entity); + expect(id).toEqual({ tenantId: 'tenant1', entityId: 42 }); + }); + }); + + describe('Error Handling', () => { + let mockRepository: MockSequelizeRepository; + + it('should handle entities without primary keys gracefully', () => { + mockRepository = new MockSequelizeRepository([], 'NoPrimaryKeyEntity'); + const service = new SequelizeFakeEntityService(mockRepository as any); + + // Should return empty array for entities without primary keys + expect(service.getIdFieldNames()).toEqual([]); + expect(service.hasCompositeId()).toBe(false); + }); + + it('should handle null primary key values', () => { + mockRepository = new MockSequelizeRepository(['id'], 'User'); + const service = new SequelizeFakeEntityService(mockRepository as any); + const entity = { id: null, email: 'test@example.com' } as User; + + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + + it('should handle undefined primary key values', () => { + mockRepository = new MockSequelizeRepository(['id'], 'User'); + const service = new SequelizeFakeEntityService(mockRepository as any); + const entity = { email: 'test@example.com' } as User; + + expect(() => service.getId(entity)).toThrow('Primary key field "id" is empty or null in entity User'); + }); + + it('should handle mixed null/undefined values in composite keys', () => { + mockRepository = new MockSequelizeRepository(['leaderId', 'followerId'], 'LeaderFollower'); + const service = new SequelizeFakeEntityService(mockRepository as any); + + // Sequelize service currently only checks for undefined, not null + // So null values are allowed and will be included in the composite key + const entityWithNull = { leaderId: 1, followerId: null } as LeaderFollower; + expect(service.getId(entityWithNull)).toEqual({ leaderId: 1, followerId: null }); + + const entityWithUndefined = { leaderId: 1 } as LeaderFollower; + expect(() => service.getId(entityWithUndefined)).toThrow('Id field "followerId" is empty'); + }); + }); + + describe('Sequelize Metadata Integration', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['id'], 'User'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should access primaryKeyAttributes from repository', () => { + expect(service.getIdFieldNames()).toEqual(['id']); + expect(service['repository'].primaryKeyAttributes).toEqual(['id']); + }); + + it('should fall back to primaryKeyAttributes when idFieldNames is empty', () => { + // Test the fallback mechanism in getIdFieldNames() + service['idFieldNames'] = []; // Clear custom field names + expect(service.getIdFieldNames()).toEqual(['id']); + }); + + it('should use custom idFieldNames when provided', () => { + service['idFieldNames'] = ['customId']; + expect(service.getIdFieldNames()).toEqual(['customId']); + }); + + it('should cache primary key detection for performance', () => { + // Call multiple times to ensure consistent behavior + const firstCall = service.getIdFieldNames(); + const secondCall = service.getIdFieldNames(); + const thirdCall = service.getIdFieldNames(); + + expect(firstCall).toEqual(secondCall); + expect(secondCall).toEqual(thirdCall); + expect(firstCall).toEqual(['id']); + }); + }); + + describe('Primary Key Validation and Edge Cases', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['leaderId', 'followerId'], 'LeaderFollower'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should validate composite key completeness in pickKeysFromObject', () => { + const completeEntity = { leaderId: 1, followerId: 2, createdAt: new Date() }; + expect(() => service['pickKeysFromObject'](completeEntity)).not.toThrow(); + + const incompleteEntity = { leaderId: 1, createdAt: new Date() }; + expect(() => service['pickKeysFromObject'](incompleteEntity)) + .toThrow('Id field "followerId" is empty'); + }); + + it('should validate all primary key fields are present', () => { + const partialKey = { leaderId: 1 }; + expect(() => service['pickKeysFromObject'](partialKey)) + .toThrow('Id field "followerId" is empty'); + }); + + it('should handle zero values in primary keys', () => { + const entityWithZero = { leaderId: 0, followerId: 1 } as any; + expect(() => service['pickKeysFromObject'](entityWithZero)).not.toThrow(); + expect(service.getId(entityWithZero)).toEqual({ leaderId: 0, followerId: 1 }); + }); + + it('should handle negative values in primary keys', () => { + const entityWithNegative = { leaderId: -1, followerId: 1 } as any; + expect(() => service['pickKeysFromObject'](entityWithNegative)).not.toThrow(); + expect(service.getId(entityWithNegative)).toEqual({ leaderId: -1, followerId: 1 }); + }); + + it('should handle string primary keys in composite scenarios', () => { + mockRepository = new MockSequelizeRepository(['stringId1', 'stringId2'], 'StringComposite'); + service = new SequelizeFakeEntityService(mockRepository as any); + + const entity = { stringId1: 'abc', stringId2: 'def', data: 'test' } as any; + expect(service.getId(entity)).toEqual({ stringId1: 'abc', stringId2: 'def' }); + }); + }); + + describe('Performance and Consistency', () => { + let mockRepository: MockSequelizeRepository; + let service: SequelizeFakeEntityService; + + beforeEach(() => { + mockRepository = new MockSequelizeRepository(['id'], 'User'); + service = new SequelizeFakeEntityService(mockRepository as any); + }); + + it('should maintain consistent primary key detection across multiple calls', () => { + const entities = [ + { id: 1, email: 'user1@test.com' } as any, + { id: 2, email: 'user2@test.com' } as any, + { id: 3, email: 'user3@test.com' } as any + ]; + + const ids = entities.map(entity => service.getId(entity)); + expect(ids).toEqual([1, 2, 3]); + }); + + it('should handle large composite keys efficiently', () => { + const largeCompositeKeys = ['key1', 'key2', 'key3', 'key4', 'key5']; + mockRepository = new MockSequelizeRepository(largeCompositeKeys, 'LargeComposite'); + service = new SequelizeFakeEntityService(mockRepository as any); + + const entity = { + key1: 'val1', + key2: 'val2', + key3: 'val3', + key4: 'val4', + key5: 'val5', + otherData: 'test' + } as any; + + const startTime = Date.now(); + const id = service.getId(entity); + const endTime = Date.now(); + + expect(id).toEqual({ + key1: 'val1', + key2: 'val2', + key3: 'val3', + key4: 'val4', + key5: 'val5' + }); + expect(endTime - startTime).toBeLessThan(10); // Should be very fast + }); + }); +}); \ No newline at end of file From 626942dc715e9f47e6dae6558c2bbd19be104eb8 Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 15:25:08 +0200 Subject: [PATCH 8/9] add integration tests for composite key operations in Sequelize, including CRUD and error handling --- src/sequelize-fake-entity.service.ts | 30 +- tests/sequelize-composite-keys.int-spec.ts | 446 ++++++++++++++++++ tests/sequelize-primary-key-detection.spec.ts | 2 +- 3 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 tests/sequelize-composite-keys.int-spec.ts diff --git a/src/sequelize-fake-entity.service.ts b/src/sequelize-fake-entity.service.ts index 0a852cf..95723e6 100644 --- a/src/sequelize-fake-entity.service.ts +++ b/src/sequelize-fake-entity.service.ts @@ -201,7 +201,7 @@ export class SequelizeFakeEntityService extends FakeEntit const idFieldName = this.getIdFieldNames()[0]; const idValue = e[idFieldName]; if (idValue === undefined || idValue === null) { - throw new Error(`Primary key field "${idFieldName}" is empty or null in entity ${this.repository.modelName}`); + throw new Error(`Primary key field "${idFieldName}" is empty or null in entity ${this.repository.name}`); } return e[this.getIdFieldNames()[0]]; } @@ -395,4 +395,32 @@ export class SequelizeFakeEntityService extends FakeEntit }); return this; } + + /** + * Build where conditions for composite primary keys + */ + private buildCompositeKeyWhere(keyValues: Record): any { + const where = {}; + for (const [key, value] of Object.entries(keyValues)) { + if (!this.getIdFieldNames().includes(key)) { + throw new Error(`Invalid primary key field "${key}" for entity ${this.repository.name}`); + } + where[key] = value; + } + return where; + } + + /** + * Find entity by composite primary key + */ + public async findByCompositeKey(keyValues: Record, transaction?: Transaction): Promise { + return this.withTransaction(async (tx) => { + const where = this.buildCompositeKeyWhere(keyValues); + const result = await this.repository.findOne({ + where, + transaction: tx + }); + return result || undefined; // Convert null to undefined + }, transaction); + } } diff --git a/tests/sequelize-composite-keys.int-spec.ts b/tests/sequelize-composite-keys.int-spec.ts new file mode 100644 index 0000000..1c788ad --- /dev/null +++ b/tests/sequelize-composite-keys.int-spec.ts @@ -0,0 +1,446 @@ +import { Sequelize } from 'sequelize-typescript'; +import { User } from './sequelize-models/user.entity'; +import { Role, RoleIds } from './sequelize-models/role.entity'; +import { LeaderFollower } from './sequelize-models/leader-follower.entity'; +import { FakeUserService } from './sequelize-factories/fake-user.service'; +import { FakeLeaderFollowerService } from './sequelize-factories/fake-leader-follower.service'; +import {Post} from "./sequelize-models/post.entity"; +import {Comment} from "./sequelize-models/comment.entity"; + +const sequelize = new Sequelize({ + host: 'localhost', + port: 54323, + dialect: 'postgres', + database: 'test-db', + username: 'tester', + password: 'test-pwd', + logging: false, + models: [User, Role, LeaderFollower, Post, Comment], +}); + +let fakeUserService: FakeUserService; +let fakeLeaderFollowerService: FakeLeaderFollowerService; +let createdRoleIds: { admin: number; customer: number; manager: number }; + +describe('Sequelize Composite Key Operations', () => { + + beforeAll(async () => { + await sequelize.authenticate(); + + // Create required roles for the tests using direct SQL to handle upserts + try { + await sequelize.query(` + INSERT INTO roles (id, name) VALUES + (${RoleIds.ADMIN}, 'ADMIN'), + (${RoleIds.CUSTOMER}, 'CUSTOMER'), + (${RoleIds.MANAGER}, 'MANAGER') + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name + `); + } catch (error) { + // Roles might already exist, which is fine + } + + // Get the actual created role IDs + const roles = await Role.findAll(); + + // Map the created roles to their actual IDs + createdRoleIds = { + admin: roles.find(r => r.name === 'ADMIN')?.id || 1, + customer: roles.find(r => r.name === 'CUSTOMER')?.id || 2, + manager: roles.find(r => r.name === 'MANAGER')?.id || 3 + }; + + fakeUserService = new FakeUserService(User); + fakeLeaderFollowerService = new FakeLeaderFollowerService(LeaderFollower); + }); + + afterAll(async () => { + await sequelize.close(); + }); + + afterEach(async () => { + await fakeLeaderFollowerService.cleanup(); + await fakeUserService.cleanup(); + }); + + describe('Primary Key Detection', () => { + it('should detect single primary key for User entity', () => { + expect(fakeUserService.getIdFieldNames()).toEqual(['id']); + expect(fakeUserService.hasCompositeId()).toBe(false); + }); + + it('should detect composite primary keys for LeaderFollower entity', () => { + expect(fakeLeaderFollowerService.getIdFieldNames()).toEqual(['leaderId', 'followerId']); + expect(fakeLeaderFollowerService.hasCompositeId()).toBe(true); + }); + + it('should access primary key metadata from repository', () => { + const userPrimaryKeys = fakeUserService.getIdFieldNames(); + expect(userPrimaryKeys).toHaveLength(1); + expect(userPrimaryKeys[0]).toBe('id'); + + const leaderFollowerPrimaryKeys = fakeLeaderFollowerService.getIdFieldNames(); + expect(leaderFollowerPrimaryKeys).toHaveLength(2); + expect(leaderFollowerPrimaryKeys.sort()).toEqual(['followerId', 'leaderId']); + }); + }); + + describe('Composite Key CRUD Operations', () => { + it('should create and retrieve LeaderFollower by composite key', async () => { + // Create users first to satisfy foreign key constraints + const leader = await fakeUserService.create({ + email: 'leader@test.com', + firstName: 'Leader', + lastName: 'User', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser = await fakeUserService.create({ + email: 'follower@test.com', + firstName: 'Follower', + lastName: 'User', + password: 'password', + roleId: createdRoleIds.customer + }); + + const leaderFollower = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + expect(leaderFollower).toBeDefined(); + expect(leaderFollower.leaderId).toBe(leader.id); + expect(leaderFollower.followerId).toBe(followerUser.id); + expect(leaderFollower.createdAt).toBeInstanceOf(Date); + + // Test getId returns composite key object + const id = fakeLeaderFollowerService.getId(leaderFollower); + expect(id).toEqual({ leaderId: leader.id, followerId: followerUser.id }); + }); + + it('should create multiple LeaderFollower entities with composite keys', async () => { + // Create a leader user + const leader = await fakeUserService.create({ + email: 'leader-multi@test.com', + firstName: 'Leader', + lastName: 'Multi', + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create multiple follower users + const followerUsers = await fakeUserService.createMany(3, { + password: 'password', + roleId: createdRoleIds.customer + }); + + const leaderFollowers = await fakeLeaderFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))).createMany(3,{ + leaderId: leader.id, + createdAt: new Date() + }); + + expect(leaderFollowers).toHaveLength(3); + + for (const leaderFollower of leaderFollowers) { + expect(leaderFollower.leaderId).toBe(leader.id); + expect(leaderFollower.followerId).toBeDefined(); + expect(leaderFollower.createdAt).toBeInstanceOf(Date); + + // Each should have a valid composite ID + const id = fakeLeaderFollowerService.getId(leaderFollower); + expect(id).toHaveProperty('leaderId'); + expect(id).toHaveProperty('followerId'); + } + }); + + it('should find entity by composite key', async () => { + // Create users first + const leader = await fakeUserService.create({ + email: 'search-leader@test.com', + firstName: 'Search', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser = await fakeUserService.create({ + email: 'search-follower@test.com', + firstName: 'Search', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }); + + const created = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + const found = await fakeLeaderFollowerService.findByCompositeKey({ + leaderId: leader.id, + followerId: followerUser.id + }); + + expect(found).toBeDefined(); + expect(found.leaderId).toBe(created.leaderId); + expect(found.followerId).toBe(created.followerId); + expect(found.createdAt).toEqual(created.createdAt); + }); + + it('should return undefined when composite key not found', async () => { + const notFound = await fakeLeaderFollowerService.findByCompositeKey({ + leaderId: 999999, + followerId: 999999 + }); + + expect(notFound).toBeUndefined(); + }); + + it('should cleanup entities by composite key', async () => { + // Create users first + const leader = await fakeUserService.create({ + email: 'delete-leader@test.com', + firstName: 'Delete', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser1 = await fakeUserService.create({ + email: 'delete-follower1@test.com', + firstName: 'Delete', + lastName: 'Follower1', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUser2 = await fakeUserService.create({ + email: 'delete-follower2@test.com', + firstName: 'Delete', + lastName: 'Follower2', + password: 'password', + roleId: createdRoleIds.customer + }); + + const leaderFollower1 = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser1.id, + createdAt: new Date() + }); + + const leaderFollower2 = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser2.id, + createdAt: new Date() + }); + + expect(fakeLeaderFollowerService.entityIds.length).toBe(2); + + // Delete using composite key objects + const deletedCount = await fakeLeaderFollowerService.cleanup(); + + expect(deletedCount).toBe(2); + + // Verify they're actually deleted + const found1 = await fakeLeaderFollowerService.findByCompositeKey({ leaderId: leader.id, followerId: followerUser1.id }); + const found2 = await fakeLeaderFollowerService.findByCompositeKey({ leaderId: leader.id, followerId: followerUser2.id }); + + expect(found1).toBeUndefined(); + expect(found2).toBeUndefined(); + }); + }); + + describe('Mixed Key Type Scenarios', () => { + it('should handle single and composite key entities in same transaction', async () => { + await sequelize.transaction(async (transaction) => { + // Create leader user with single key + const leader = await fakeUserService.create({ + email: 'transaction-leader@example.com', + firstName: 'John', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }, transaction); + + // Create follower user + const followerUser = await fakeUserService.create({ + email: 'transaction-follower@example.com', + firstName: 'Jane', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }, transaction); + + expect(leader.id).toBeDefined(); + expect(fakeUserService.getId(leader)).toBe(leader.id); + + // Create leader-follower relationship with composite key + const leaderFollower = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }, transaction); + + expect(leaderFollower.leaderId).toBe(leader.id); + expect(leaderFollower.followerId).toBe(followerUser.id); + + const compositeId = fakeLeaderFollowerService.getId(leaderFollower); + expect(compositeId).toEqual({ leaderId: leader.id, followerId: followerUser.id }); + }); + }); + + it('should maintain referential integrity with composite keys', async () => { + // Create two users first + const leader = await fakeUserService.create({ + email: 'integrity-leader@test.com', + firstName: 'Integrity', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.admin + }); + + const followerUser = await fakeUserService.create({ + email: 'integrity-follower@test.com', + firstName: 'Integrity', + lastName: 'Follower', + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create leader-follower relationship that references both users + const leaderFollower = await fakeLeaderFollowerService.create({ + leaderId: leader.id, + followerId: followerUser.id, + createdAt: new Date() + }); + + expect(leaderFollower.leaderId).toBe(leader.id); + expect(leaderFollower.followerId).toBe(followerUser.id); + + // Verify both entities exist and are properly linked + const foundLeader = await fakeUserService.findByCompositeKey({ id: leader.id }); + const foundLeaderFollower = await fakeLeaderFollowerService.findByCompositeKey({ + leaderId: leader.id, + followerId: followerUser.id + }); + + expect(foundLeader).toBeDefined(); + expect(foundLeaderFollower).toBeDefined(); + expect(foundLeaderFollower.leaderId).toBe(foundLeader.id); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should throw error for missing composite key fields', () => { + expect(() => fakeLeaderFollowerService.getId({ leaderId: 1 } as LeaderFollower)) + .toThrow('Id field "followerId" is empty'); + }); + + it('should throw error for invalid composite key field in where clause', () => { + expect(() => fakeLeaderFollowerService['buildCompositeKeyWhere']({ leaderId: 1, invalidField: 2 })) + .toThrow('Invalid primary key field "invalidField" for entity LeaderFollower'); + }); + + it('should handle null values in composite keys gracefully', () => { + expect(fakeLeaderFollowerService.getId({ leaderId: 1, followerId: null } as LeaderFollower)) + .toEqual({ leaderId: 1, followerId: null }); + }); + + it('should handle undefined values in composite keys gracefully', () => { + expect(() => fakeLeaderFollowerService.getId({ leaderId: 1 } as LeaderFollower)) + .toThrow('Id field "followerId" is empty'); + }); + }); + + describe('Performance and Bulk Operations', () => { + it('should efficiently handle bulk composite key operations', async () => { + // Create users for the test + const leader = await fakeUserService.create({ + email: 'bulk-leader@test.com', + firstName: 'Bulk', + lastName: 'Leader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUsers = await fakeUserService.createMany(50, { + password: 'password', + roleId: createdRoleIds.customer + }); + + const startTime = Date.now(); + + // Create 50 leader-follower relationships using addStates pattern + const leaderFollowers = await fakeLeaderFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))) + .createMany(50, { + leaderId: leader.id, + createdAt: new Date() + }); + + const creationTime = Date.now() - startTime; + expect(creationTime).toBeLessThan(5000); // Should complete within 5 seconds + + expect(leaderFollowers).toHaveLength(50); + + // Verify all have valid composite IDs + for (const leaderFollower of leaderFollowers) { + const id = fakeLeaderFollowerService.getId(leaderFollower); + expect(id).toHaveProperty('leaderId'); + expect(id).toHaveProperty('followerId'); + expect(typeof id.leaderId).toBe('number'); + expect(typeof id.followerId).toBe('number'); + } + }); + + it('should efficiently delete multiple entities by composite keys', async () => { + // Create users for the test + const leader = await fakeUserService.create({ + email: 'delete-bulk-leader@test.com', + firstName: 'Delete', + lastName: 'BulkLeader', + password: 'password', + roleId: createdRoleIds.customer + }); + + const followerUsers = await fakeUserService.createMany(10, { + password: 'password', + roleId: createdRoleIds.customer + }); + + // Create test data using addStates pattern + const leaderFollowers = await fakeLeaderFollowerService + .addStates(followerUsers.map(user => ({ + followerId: user.id, + }))) + .createMany(10, { + leaderId: leader.id, + createdAt: new Date() + }); + + // Extract composite IDs for deletion + const compositeIds = leaderFollowers.map(f => fakeLeaderFollowerService.getId(f)); + + const startTime = Date.now(); + const deletedCount = await fakeLeaderFollowerService.delete(compositeIds); + const deletionTime = Date.now() - startTime; + + expect(deletedCount).toBe(10); + expect(deletionTime).toBeLessThan(2000); // Should complete within 2 seconds + + // Verify all are deleted + for (const id of compositeIds) { + const found = await fakeLeaderFollowerService.findByCompositeKey(id); + expect(found).toBeUndefined(); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/sequelize-primary-key-detection.spec.ts b/tests/sequelize-primary-key-detection.spec.ts index a68ec8c..3127137 100644 --- a/tests/sequelize-primary-key-detection.spec.ts +++ b/tests/sequelize-primary-key-detection.spec.ts @@ -4,7 +4,7 @@ import {LeaderFollower} from "./sequelize-models/leader-follower.entity"; // Mock repository for testing Sequelize primary key detection class MockSequelizeRepository { - constructor(public primaryKeyAttributes: string[], public modelName: string = 'TestModel') {} + constructor(public primaryKeyAttributes: string[], public name: string = 'TestModel') {} // Mock Sequelize methods create = jest.fn(); From 878d10a02b222c2ae60699be08f128fd2e5e8cc5 Mon Sep 17 00:00:00 2001 From: Mike Onofrienko Date: Sun, 22 Jun 2025 15:34:54 +0200 Subject: [PATCH 9/9] update documentation and version to 0.10.0 --- CHANGELOG.md | 26 +++++++++++++++++ README.md | 81 ++++++++++++++++++++++++++++++++++++++-------------- package.json | 2 +- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e5c3a..d4b3d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to this project will be documented in this file. +## [0.10.0] - 2025-06-22 +### Added +- **Composite Primary Key Support**: Full support for multi-column primary keys in both TypeORM and Sequelize +- **Automatic Primary Key Detection**: Both ORMs now automatically detect primary keys from entity metadata + - TypeORM: Reads from `@PrimaryColumn` and `@PrimaryGeneratedColumn` decorators + - Sequelize: Uses model's `primaryKeyAttributes` property +- **Enhanced TypeORM Service**: Complete rewrite of primary key handling with composite key support +- **New Methods**: Added `findByCompositeKey()`, `hasCompositeId()`, `getIdFieldNames()`, and `getPrimaryColumns()` methods +- **Improved Error Handling**: Enhanced validation and error messages for primary key operations +- **Comprehensive Test Coverage**: Added 900+ lines of integration tests covering: + - Single and composite primary key detection + - CRUD operations with composite keys + - Error handling and edge cases + - Entity cleanup with composite keys + +### Changed +- **BREAKING**: TypeORM service now uses `idFieldNames: string[]` instead of `idFieldName: string` +- **BREAKING**: Primary key detection is now automatic; manual override requires `idFieldNames` array +- **Enhanced**: Improved entity cleanup logic for both single and composite primary keys +- **Enhanced**: Better error messages with entity name context + +### Fixed +- Fixed entity cleanup to properly handle composite primary keys +- Fixed primary key validation to handle null/undefined values +- Improved transaction handling for composite key operations + ## [0.9.2] - 2025-05-12 ### Added - Introduced the `clone()` method to core and all services, enabling creation of isolated service instances with shared repository and empty state. diff --git a/README.md b/README.md index 2158da7..026c2b5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ But since it's a TypeScript library, it has quite different syntax. ## Key Features - ๐Ÿ”„ **ORM Agnostic** - Works with both Sequelize and TypeORM +- ๐Ÿ”‘ **Composite Primary Key Support** - Full support for single and multi-column primary keys +- ๐Ÿ” **Automatic Primary Key Detection** - Automatically detects primary keys from entity metadata - ๐Ÿงฉ **Relationship Support** - Easily define parent-child and nested relationships - ๐Ÿงช **Testing-Focused** - Designed specifically for integration and e2e tests - ๐Ÿงน **Automatic Cleanup** - Tracks and cleans up created entities @@ -607,29 +609,60 @@ this code will delete all users created by the `fakeUserService` service. #### Primary keys -As you can see in the examples above, we need to describe primary key column name for the entity model -to track created entities and to delete them later. +The library automatically detects primary keys from your entity metadata for both TypeORM and Sequelize. -Primary key description is ORM specific. +**Automatic Primary Key Detection:** +- **TypeORM**: Reads `@PrimaryColumn` and `@PrimaryGeneratedColumn` decorators from entity metadata +- **Sequelize**: Uses the model's `primaryKeyAttributes` property +- **Composite Keys**: Full support for multi-column primary keys in both ORMs -For Sequelize we support automatic detection of primary keys both for single column and multi-column primary keys. -see [Sequelize specific features](#sequelize-specific-features) section below. +The library tracks created entities using their primary key values and provides cleanup functionality: -Unfortunately, automatic detection of primary keys is not applied for TypeORM version of the library. -Thus, we use `id` field as a default primary key column for TypeORM. -But you can override it by passing `idFieldName` property to your service class: +```typescript +// For single primary key entities +const users = await fakeUserService.createMany(5); +await fakeUserService.cleanup(); // Deletes all created users + +// For composite primary key entities +const followers = await fakeFollowerService.createMany(3, { + userId: 1, + followerId: 2 +}); +await fakeFollowerService.cleanup(); // Handles composite key cleanup +``` + +**Working with Composite Primary Keys:** ```typescript -import {TypeormFakeEntityService} from "./typeorm-fake-entity.service"; +// Find entity by composite key +const follower = await fakeFollowerService.findByCompositeKey({ + userId: 1, + followerId: 2 +}); +// Check if entity has composite primary key +if (fakeFollowerService.hasCompositeId()) { + console.log('Entity uses composite primary key'); + console.log('Key fields:', fakeFollowerService.getIdFieldNames()); +} +``` + +**Manual Override (if needed):** +While automatic detection works for most cases, you can override the primary key fields: + +```typescript +// TypeORM export class FakeUserService extends TypeormFakeEntityService { - public idFieldName = 'uuid'; + public idFieldNames = ['uuid']; // Override detected primary keys + // ... +} +// Sequelize +export class FakeUserService extends SequelizeFakeEntityService { + public idFieldNames = ['customId']; // Override detected primary keys // ... - // constructor and other methods } -``` -Multi-column primary keys are not supported for `TypeormFakeEntityService` yet. +``` ### Callbacks @@ -695,20 +728,26 @@ await cloneB.create(); // Creates a user 'Bob' in cloneB > **Note:** The `clone()` method assumes your service constructor accepts a repository as the first argument and that a `repository` property exists. If your subclass uses a different signature, override `clone()` accordingly. -## Sequelize specific features -- Use Sequelize's primary keys detection. The library uses Sequelize's model `primaryKeyAttributes` property to detect primary keys. -> If you need to override it, you can pass `idFieldName` property to your service class: +## ORM-Specific Features -- The library can work with multi-column primary keys. It also Sequelize's model `primaryKeyAttributes` property to detect them. +### Sequelize Features +- **Automatic Primary Key Detection**: Uses Sequelize's model `primaryKeyAttributes` property to detect both single and composite primary keys +- **Composite Primary Key Support**: Full CRUD operations support for multi-column primary keys +- **Sequelize Relations**: Integration with Sequelize's built-in associations for nested entity creation -- The library can work with Sequelize's relations. If you described relations in your model, the library will use them to create nested entities. -> For example, if you have `User` and `Notification` models and `User.hasMany(Notification)` relation, you can describe `withNotifications` method from previous example like below: +### TypeORM Features +- **Automatic Primary Key Detection**: Reads primary key metadata from `@PrimaryColumn` and `@PrimaryGeneratedColumn` decorators +- **Composite Primary Key Support**: Complete support for multi-column primary keys with automatic detection +- **Enhanced Error Handling**: Detailed validation messages for primary key operations + +### Using Sequelize Relations +If you have described relations in your Sequelize model, the library can use them to create nested entities: +> For example, if you have `User` and `Notification` models and `User.hasMany(Notification)` relation, you can describe `withNotifications` method like below: ```typescript -export class FakePostService extends SequelizeFakeEntityService { +export class FakeUserService extends SequelizeFakeEntityService { // constructor and other methods // ... - withNotifications(fakeNotificationService: FakeNotificationService, count: number, customFields?: Partial): FakeUserService { this.nestedEntities.push({ service: fakeNotificationService, diff --git a/package.json b/package.json index 6c22194..9b7e880 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fake-entity-service", - "version": "0.9.3", + "version": "0.10.0", "description": "A fake database entities service for testing", "author": { "email": "mike.onofrienko@clockwise.software",