diff --git a/.changeset/stale-carrots-push.md b/.changeset/stale-carrots-push.md new file mode 100644 index 00000000..199e9304 --- /dev/null +++ b/.changeset/stale-carrots-push.md @@ -0,0 +1,6 @@ +--- +'manifest': minor +'@mnfst/sdk': minor +--- + +Added PATCH requests for item update diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..e6574a44 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,22 @@ +name-template: 'v$NEXT_PATCH_VERSION' +tag-template: 'v$NEXT_PATCH_VERSION' +categories: + - title: '🚀 Features' + labels: + - enhancement + - title: '🐛 Bug Fixes' + labels: + - bug + - title: '🛠 Maintenance' + labels: + - chore + - refactor + - tests + - dependencies +change-template: '- $TITLE (#$NUMBER)' +no-changes-template: '- No changes' + +template: | + ## What's Changed + + $CHANGES diff --git a/.github/workflows/release.yml b/.github/workflows/publish.yml similarity index 98% rename from .github/workflows/release.yml rename to .github/workflows/publish.yml index 51918e59..87e9bc9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Release +name: Publish on: push: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000..fd1699fa --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,30 @@ +name: Release Drafter + +on: + push: + tags: + - 'manifest@*.*.*' # Match tags for "manifest" + - '@mnfst/sdk@*.*.*' # Match tags for "@mnfst/sdk" + - 'add-manifest@*.*.*' # Match tags for "add-manifest" + workflow_dispatch: # Allow manual triggering of the workflow + +permissions: + contents: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + # Step 1: Checkout the repository + - name: Checkout repository + uses: actions/checkout@v3 + + # Step 2: Run Release Drafter to draft and publish the release + - name: Draft and Publish Release + uses: release-drafter/release-drafter@v5 + with: + config-name: release.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/eslint.config.mjs b/eslint.config.mjs index 80a75744..bc7a9e96 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,5 +9,12 @@ export default [ }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, - ...tseslint.configs.recommended + ...tseslint.configs.recommended, + { + // Allow "any" in test files. + files: ['**/*.spec.ts', '**/*.e2e-spec.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off' + } + } ] diff --git a/packages/core/manifest/e2e/tests/collection-crud.e2e-spec.ts b/packages/core/manifest/e2e/tests/collection-crud.e2e-spec.ts index c314e4dc..bc17e6dc 100644 --- a/packages/core/manifest/e2e/tests/collection-crud.e2e-spec.ts +++ b/packages/core/manifest/e2e/tests/collection-crud.e2e-spec.ts @@ -15,12 +15,14 @@ describe('Collection CRUD (e2e)', () => { location: { lat: 12, lng: 13 } } - it('POST /collections/:entity', async () => { - const response = await global.request - .post('/collections/dogs') - .send(dummyDog) + describe('POST /collections/:entity', () => { + it('should create an item', async () => { + const response = await global.request + .post('/collections/dogs') + .send(dummyDog) - expect(response.status).toBe(201) + expect(response.status).toBe(201) + }) }) describe('GET /collections/:entity', () => { @@ -69,52 +71,121 @@ describe('Collection CRUD (e2e)', () => { }) }) - it('GET /collections/:entity/select-options', async () => { - const response = await global.request.get( - '/collections/dogs/select-options' - ) + describe('GET /collections/:entity/select-options', () => { + it('should get select options', async () => { + const response = await global.request.get( + '/collections/dogs/select-options' + ) - expect(response.status).toBe(200) - expect(response.body).toMatchObject([ - { - label: dummyDog.name, - id: 1 - } - ]) + expect(response.status).toBe(200) + expect(response.body).toMatchObject([ + { + label: dummyDog.name, + id: 1 + } + ]) + }) }) - it('GET /collections/:entity/:id', async () => { - const response = await global.request.get('/collections/dogs/1') + describe('GET /collections/:entity/:id', () => { + it('should return an item', async () => { + const response = await global.request.get('/collections/dogs/1') - expect(response.status).toBe(200) - expect(response.body).toMatchObject(dummyDog) + expect(response.status).toBe(200) + expect(response.body).toMatchObject(dummyDog) + }) + }) + + describe('PUT /collections/:entity/:id', () => { + it('should fully update an item', async () => { + const newName = 'Rex' + + const response = await global.request.put('/collections/dogs/1').send({ + name: newName + }) + + expect(response.status).toBe(200) + + const updatedResponse = await global.request.get('/collections/dogs/1') + + expect(updatedResponse.status).toBe(200) + expect(updatedResponse.body).toMatchObject({ + name: newName + }) + }) }) - it('PUT /collections/:entity/:id', async () => { - const newName = 'Rex' + describe('PATCH /collections/:entity/:id', () => { + it('should patch an item', async () => { + const postResponse = await global.request + .post('/collections/dogs') + .send(dummyDog) + + const newAge = 6 + + const response = await global.request + .patch(`/collections/dogs/${postResponse.body.id}`) + .send({ + age: newAge + }) + + expect(response.status).toBe(200) - const response = await global.request.put('/collections/dogs/1').send({ - name: newName + const updatedResponse = await global.request.get( + `/collections/dogs/${postResponse.body.id}` + ) + + expect(updatedResponse.status).toBe(200) + expect(updatedResponse.body).toMatchObject({ + ...dummyDog, + age: newAge + }) }) - expect(response.status).toBe(200) + it('should keep relations if not provided', async () => { + const createOwnerResponse = await global.request + .post('/collections/owners') + .send({ + name: 'John Doe' + }) - const updatedResponse = await global.request.get('/collections/dogs/1') + const dogWithOwner = { + name: 'Charlie', + ownerId: createOwnerResponse.body.id + } - expect(updatedResponse.status).toBe(200) - expect(updatedResponse.body).toMatchObject({ - ...dummyDog, - name: newName + const createResponse = await global.request + .post('/collections/dogs') + .send(dogWithOwner) + + expect(createResponse.status).toBe(201) + + const updateResponse = await global.request + .patch(`/collections/dogs/${createResponse.body.id}`) + .send({ + name: 'Charlie 2' + }) + + expect(updateResponse.status).toBe(200) + + const fetchResponse = await global.request.get( + `/collections/dogs/${createResponse.body.id}?relations=owner` + ) + + expect(fetchResponse.status).toBe(200) + expect(fetchResponse.body?.owner?.id).toEqual(1) }) }) - it('DELETE /collections/:entity/:id', async () => { - const response = await global.request.delete('/collections/dogs/1') + describe('DELETE /collections/:entity/:id', () => { + it('should delete an item', async () => { + const response = await global.request.delete('/collections/dogs/1') - expect(response.status).toBe(200) + expect(response.status).toBe(200) - const updatedResponse = await global.request.get('/collections/dogs/1') + const updatedResponse = await global.request.get('/collections/dogs/1') - expect(updatedResponse.status).toBe(404) + expect(updatedResponse.status).toBe(404) + }) }) }) diff --git a/packages/core/manifest/e2e/tests/single-crud.e2e-spec.ts b/packages/core/manifest/e2e/tests/single-crud.e2e-spec.ts index 63740256..5175cb31 100644 --- a/packages/core/manifest/e2e/tests/single-crud.e2e-spec.ts +++ b/packages/core/manifest/e2e/tests/single-crud.e2e-spec.ts @@ -48,7 +48,7 @@ describe('Single CRUD (e2e)', () => { }) }) - describe('PUT /collections/:entity', () => { + describe('PUT /singles/:entity', () => { it('can update a single entity', async () => { const newTitle: string = 'Contact Us' diff --git a/packages/core/manifest/e2e/tests/validation.e2e-spec.ts b/packages/core/manifest/e2e/tests/validation.e2e-spec.ts index 2ae84370..11f99935 100644 --- a/packages/core/manifest/e2e/tests/validation.e2e-spec.ts +++ b/packages/core/manifest/e2e/tests/validation.e2e-spec.ts @@ -164,7 +164,7 @@ describe('Validation (e2e)', () => { const updateResponse = await global.request .put('/collections/super-users/1') - .send({ name: 'new name' }) + .send({ name: 'new name', email: 'example2@manifest.build' }) expect(badCreateResponse.status).toBe(400) expect( diff --git a/packages/core/manifest/src/crud/controllers/collection.controller.ts b/packages/core/manifest/src/crud/controllers/collection.controller.ts index 11cf3e21..aa404f19 100644 --- a/packages/core/manifest/src/crud/controllers/collection.controller.ts +++ b/packages/core/manifest/src/crud/controllers/collection.controller.ts @@ -5,6 +5,7 @@ import { Get, Param, ParseIntPipe, + Patch, Post, Put, Query, @@ -86,12 +87,27 @@ export class CollectionController { @Put(':entity/:id') @Rule('update') - update( - @Param('entity') entity: string, + put( + @Param('entity') entitySlug: string, @Param('id', ParseIntPipe) id: number, - @Body() entityDto: Partial + @Body() itemDto: Partial + ): Promise { + return this.crudService.update({ entitySlug, id, itemDto }) + } + + @Patch(':entity/:id') + @Rule('update') + patch( + @Param('entity') entitySlug: string, + @Param('id', ParseIntPipe) id: number, + @Body() itemDto: Partial ): Promise { - return this.crudService.update(entity, id, entityDto) + return this.crudService.update({ + entitySlug, + id, + itemDto, + partialReplacement: true + }) } @Delete(':entity/:id') diff --git a/packages/core/manifest/src/crud/controllers/single.controller.ts b/packages/core/manifest/src/crud/controllers/single.controller.ts index 9abeb6aa..1f166ea2 100644 --- a/packages/core/manifest/src/crud/controllers/single.controller.ts +++ b/packages/core/manifest/src/crud/controllers/single.controller.ts @@ -4,6 +4,7 @@ import { Get, NotFoundException, Param, + Patch, Put, Req, UseGuards @@ -55,10 +56,24 @@ export class SingleController { @Put(':entity') @Rule('update') - update( - @Param('entity') entity: string, - @Body() entityDto: Partial + put( + @Param('entity') entitySlug: string, + @Body() itemDto: Partial + ): Promise { + return this.crudService.update({ entitySlug, id: 1, itemDto }) + } + + @Patch(':entity') + @Rule('update') + patch( + @Param('entity') entitySlug: string, + @Body() itemDto: Partial ): Promise { - return this.crudService.update(entity, 1, entityDto) + return this.crudService.update({ + entitySlug, + id: 1, + itemDto, + partialReplacement: true + }) } } diff --git a/packages/core/manifest/src/crud/services/crud.service.ts b/packages/core/manifest/src/crud/services/crud.service.ts index fa26f721..5d50c852 100644 --- a/packages/core/manifest/src/crud/services/crud.service.ts +++ b/packages/core/manifest/src/crud/services/crud.service.ts @@ -237,12 +237,12 @@ export class CrudService { const newItem: BaseEntity = entityRepository.create(itemDto) const relationItems: { [key: string]: BaseEntity | BaseEntity[] } = - await this.relationshipService.fetchRelationItemsFromDto( + await this.relationshipService.fetchRelationItemsFromDto({ itemDto, - entityManifest.relationships + relationships: entityManifest.relationships .filter((r) => r.type !== 'one-to-many') .filter((r) => r.type !== 'many-to-many' || r.owningSide) - ) + }) if (entityManifest.authenticable && itemDto.password) { newItem.password = SHA3(newItem.password).toString() @@ -274,11 +274,27 @@ export class CrudService { return entityRepository.save({}) } - async update( - entitySlug: string, - id: number, + /* + * Updates an item doing a FULL REPLACEMENT of the item properties and relations unless partialReplacement is set to true. + * + * @param entitySlug the entity slug. + * @param id the item id. + * @param itemDto the item dto. + * @param partialReplacement whether to do a partial replacement. + * + * @returns the updated item. + */ + async update({ + entitySlug, + id, + itemDto, + partialReplacement + }: { + entitySlug: string + id: number itemDto: Partial - ): Promise { + partialReplacement?: boolean + }): Promise { const entityManifest: EntityManifest = this.entityManifestService.getEntityManifest({ slug: entitySlug, @@ -290,22 +306,35 @@ export class CrudService { const item: BaseEntity = await entityRepository.findOne({ where: { id } }) + if (!item) { + throw new NotFoundException('Item not found') + } + const relationItems: { [key: string]: BaseEntity | BaseEntity[] } = - await this.relationshipService.fetchRelationItemsFromDto( + await this.relationshipService.fetchRelationItemsFromDto({ itemDto, - entityManifest.relationships + relationships: entityManifest.relationships .filter((r) => r.type !== 'one-to-many') - .filter((r) => r.type !== 'many-to-many' || r.owningSide) - ) + .filter((r) => r.type !== 'many-to-many' || r.owningSide), + emptyMissing: !partialReplacement + }) - if (!item) { - throw new NotFoundException('Item not found') + // On partial replacement, only update the provided props. + if (partialReplacement) { + itemDto = { ...item, ...itemDto } + + // Remove undefined values to keep the existing values. + Object.keys(relationItems).forEach((key: string) => { + if ( + relationItems[key] === undefined || + relationItems[key]?.length === 0 + ) { + delete relationItems[key] + } + }) } - const updatedItem: BaseEntity = entityRepository.create({ - ...item, - ...itemDto - } as BaseEntity) + const updatedItem: BaseEntity = entityRepository.create({ id, ...itemDto }) // Hash password if it exists. if (entityManifest.authenticable && itemDto.password) { diff --git a/packages/core/manifest/src/crud/tests/collection.controller.spec.ts b/packages/core/manifest/src/crud/tests/collection.controller.spec.ts index 2561aa64..d5c9a32d 100644 --- a/packages/core/manifest/src/crud/tests/collection.controller.spec.ts +++ b/packages/core/manifest/src/crud/tests/collection.controller.spec.ts @@ -8,6 +8,7 @@ import { EntityManifestService } from '../../manifest/services/entity-manifest.s describe('CollectionController', () => { let controller: CollectionController + let crudService: CrudService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -16,14 +17,18 @@ describe('CollectionController', () => { { provide: AuthService, useValue: { - isReqUserAdmin: jest.fn() + isReqUserAdmin: jest.fn(() => Promise.resolve(false)) } }, { provide: CrudService, useValue: { findOne: jest.fn(), - update: jest.fn() + findAll: jest.fn(), + findSelectOptions: jest.fn(), + store: jest.fn(), + update: jest.fn(), + delete: jest.fn() } }, { @@ -48,9 +53,99 @@ describe('CollectionController', () => { }).compile() controller = module.get(CollectionController) + crudService = module.get(CrudService) }) it('should be defined', () => { expect(controller).toBeDefined() }) + + it('should call crudService.findAll', async () => { + const entitySlug = 'cats' + const queryParams = {} + const req = {} as any + + await controller.findAll(entitySlug, queryParams, req) + + expect(crudService.findAll).toHaveBeenCalledWith({ + entitySlug, + queryParams, + fullVersion: false + }) + }) + + it('should call crudService.findSelectOptions', async () => { + const entitySlug = 'cats' + const queryParams = {} + + await controller.findSelectOptions(entitySlug, queryParams) + + expect(crudService.findSelectOptions).toHaveBeenCalledWith({ + entitySlug, + queryParams + }) + }) + + it('should call crudService.findOne', async () => { + const entitySlug = 'cats' + const id = 1 + const queryParams = {} + const req = {} as any + + await controller.findOne(entitySlug, id, queryParams, req) + + expect(crudService.findOne).toHaveBeenCalledWith({ + entitySlug, + id, + queryParams, + fullVersion: false + }) + }) + + it('should call crudService.store', async () => { + const entity = 'cats' + const itemDto = {} + + await controller.store(entity, itemDto) + + expect(crudService.store).toHaveBeenCalledWith(entity, itemDto) + }) + + it('should call crudService.update', async () => { + const entitySlug = 'cats' + const id = 1 + const itemDto = {} + + await controller.put(entitySlug, id, itemDto) + + expect(crudService.update).toHaveBeenCalledWith({ + entitySlug, + id, + itemDto: itemDto + }) + }) + + it('should call crudService.update with partialReplacement', async () => { + const entitySlug = 'cats' + const id = 1 + const itemDto = {} + + await controller.patch(entitySlug, id, itemDto) + + expect(crudService.update).toHaveBeenCalledWith({ + entitySlug, + id, + itemDto: itemDto, + partialReplacement: true + }) + }) + + it('should call crudService.delete', async () => { + const entitySlug = 'cats' + const id = 1 + + await controller.delete(entitySlug, id) + + expect(crudService.delete).toHaveBeenCalledWith(entitySlug, id) + }) }) diff --git a/packages/core/manifest/src/crud/tests/crud.service.spec.ts b/packages/core/manifest/src/crud/tests/crud.service.spec.ts index 476c8997..ac55d37a 100644 --- a/packages/core/manifest/src/crud/tests/crud.service.spec.ts +++ b/packages/core/manifest/src/crud/tests/crud.service.spec.ts @@ -7,6 +7,15 @@ import { ValidationService } from '../../validation/services/validation.service' import { RelationshipService } from '../../entity/services/relationship.service' describe('CrudService', () => { let service: CrudService + let entityService: EntityService + let validationService: ValidationService + + const dummyItem = { + name: 'Superman', + age: 30, + color: 'blue', + mentorId: 3 + } beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -15,7 +24,9 @@ describe('CrudService', () => { { provide: EntityManifestService, useValue: { - getEntityRepository: jest.fn() + getEntityManifest: jest.fn(() => ({ + relationships: [] + })) } }, { @@ -27,28 +38,177 @@ describe('CrudService', () => { { provide: EntityService, useValue: { - findOne: jest.fn() + findOne: jest.fn(), + getEntityRepository: jest.fn(() => ({ + findOne: jest.fn(() => Promise.resolve(dummyItem)), + create: jest.fn((item) => item), + save: jest.fn((item) => item) + })) } }, { provide: ValidationService, useValue: { - validate: jest.fn() + validate: jest.fn(() => []) } }, { provide: RelationshipService, useValue: { - fetchRelationItemsFromDto: jest.fn() + fetchRelationItemsFromDto: jest.fn(({ itemDto, emptyMissing }) => { + if (itemDto.mentorId) { + return { mentor: { id: itemDto.mentorId } } + } + if (emptyMissing) { + return { mentor: null } + } + return {} + }) } } ] }).compile() service = module.get(CrudService) + entityService = module.get(EntityService) + validationService = module.get(ValidationService) }) it('should be defined', () => { expect(service).toBeDefined() }) + + describe('update', () => { + it('should update an entity', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { name: 'test' } + + const result = await service.update({ entitySlug, id, itemDto }) + + expect(result.name).toEqual(itemDto.name) + }) + + it('should throw an error if the entity is not found', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { name: 'test' } + + jest.spyOn(entityService, 'getEntityRepository').mockReturnValue({ + findOne: jest.fn(() => Promise.resolve(null)), + create: jest.fn((item) => item), + save: jest.fn((item) => item) + } as any) + + await expect( + service.update({ entitySlug, id, itemDto }) + ).rejects.toThrow() + }) + + it('should update relationships', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { mentorId: 2 } + + const result: any = await service.update({ entitySlug, id, itemDto }) + + expect(result.mentor.id).toEqual(itemDto.mentorId) + }) + + it('should do a full replacement of properties', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { name: 'test' } + + const result = await service.update({ entitySlug, id, itemDto }) + + expect(result.name).toEqual(itemDto.name) + expect(result.age).toBeUndefined() + }) + + it('should do a full replacement of relationships', async () => { + const entitySlug = 'test' + const id = 1 + const itemWithoutRelation = {} + const itemWithNewRelation = { mentorId: 2 } + + const resultWithoutRelation = await service.update({ + entitySlug, + id, + itemDto: itemWithoutRelation + }) + const resultWithNewRelation = await service.update({ + entitySlug, + id, + itemDto: itemWithNewRelation + }) + + expect(resultWithoutRelation.mentor).toBeNull() + expect(resultWithNewRelation.mentor['id']).toEqual( + itemWithNewRelation.mentorId + ) + }) + + it('should throw an error if validation fails', async () => { + jest.spyOn(validationService, 'validate').mockReturnValue([ + { + property: 'name', + constraints: { + isNotEmpty: 'name should not be empty' + } + } + ]) + + const entitySlug = 'test' + const id = 1 + const itemDto = { name: '' } + + expect(service.update({ entitySlug, id, itemDto })).rejects.toThrow() + }) + + describe('update (partial replacement)', () => { + it('should do a partial replacement of properties', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { name: 'test' } + + const result = await service.update({ + entitySlug, + id, + itemDto, + partialReplacement: true + }) + + expect(result.name).toEqual(itemDto.name) + expect(result.age).toEqual(dummyItem.age) + }) + + it('should replace relations only if specified', async () => { + const entitySlug = 'test' + const id = 1 + const itemDto = { mentorId: 2 } + const itemWithoutRelationDto = { name: 'test' } + + const result = await service.update({ + entitySlug, + id, + itemDto, + partialReplacement: true + }) + + const resultWithoutRelation = await service.update({ + entitySlug, + id, + itemDto: itemWithoutRelationDto, + partialReplacement: true + }) + + expect(result.mentor['id']).toEqual(itemDto.mentorId) + expect(result.name).toEqual(dummyItem.name) + + expect(resultWithoutRelation.mentor).toBeUndefined() + expect(resultWithoutRelation.name).toEqual(itemWithoutRelationDto.name) + }) + }) + }) }) diff --git a/packages/core/manifest/src/crud/tests/database.controller.spec.ts b/packages/core/manifest/src/crud/tests/database.controller.spec.ts index 80525a7d..bcf6d309 100644 --- a/packages/core/manifest/src/crud/tests/database.controller.spec.ts +++ b/packages/core/manifest/src/crud/tests/database.controller.spec.ts @@ -24,4 +24,9 @@ describe('DatabaseController', () => { it('should be defined', () => { expect(controller).toBeDefined() }) + + it('should return true if the database is empty', async () => { + const res = await controller.isDbEmpty() + expect(res).toEqual({ empty: true }) + }) }) diff --git a/packages/core/manifest/src/crud/tests/single.controller.spec.ts b/packages/core/manifest/src/crud/tests/single.controller.spec.ts index f62786f0..b90e75ee 100644 --- a/packages/core/manifest/src/crud/tests/single.controller.spec.ts +++ b/packages/core/manifest/src/crud/tests/single.controller.spec.ts @@ -94,4 +94,35 @@ describe('SingleController', () => { expect(res).toEqual(emptyRecord) }) }) + + describe('PUT :entity', () => { + it('should call crudService.update with ID 1', async () => { + const entitySlug = 'test' + const itemDto = { name: 'test' } + + await controller.put(entitySlug, itemDto as any) + + expect(crudService.update).toHaveBeenCalledWith({ + entitySlug, + id: 1, + itemDto + }) + }) + }) + + describe('PATCH :entity', () => { + it('should call crudService.update with ID 1 and partialReplacement true', async () => { + const entitySlug = 'test' + const itemDto = { name: 'test' } + + await controller.patch(entitySlug, itemDto as any) + + expect(crudService.update).toHaveBeenCalledWith({ + entitySlug, + id: 1, + itemDto, + partialReplacement: true + }) + }) + }) }) diff --git a/packages/core/manifest/src/entity/services/relationship.service.ts b/packages/core/manifest/src/entity/services/relationship.service.ts index 7a79d930..115b317c 100644 --- a/packages/core/manifest/src/entity/services/relationship.service.ts +++ b/packages/core/manifest/src/entity/services/relationship.service.ts @@ -156,13 +156,19 @@ export class RelationshipService { * * @param itemDto The DTO to fetch the related items for. * @param relationships The relationships to fetch the related items for. + * @param emptyMissing If true, missing relationships will be emptied from the DTO by returning null or an empty array. * * @returns A "relationItems" object with the related items. * */ - async fetchRelationItemsFromDto( - itemDto: object, + async fetchRelationItemsFromDto({ + itemDto, + relationships, + emptyMissing + }: { + itemDto: object relationships: RelationshipManifest[] - ): Promise<{ [key: string]: BaseEntity | BaseEntity[] }> { + emptyMissing?: boolean + }): Promise<{ [key: string]: BaseEntity | BaseEntity[] }> { const fetchPromises: { [key: string]: Promise } = {} @@ -187,17 +193,19 @@ export class RelationshipService { id: In(relationIds) }) } else { - fetchPromises[relationship.name] = - relationship.type === 'many-to-one' - ? Promise.resolve(null) - : Promise.resolve([]) + if (emptyMissing) { + fetchPromises[relationship.name] = + relationship.type === 'many-to-one' + ? Promise.resolve(null) + : Promise.resolve([]) + } } }) const relationItems: { [key: string]: BaseEntity | BaseEntity[] } = {} - for (const [key, value] of Object.entries(fetchPromises)) { - relationItems[key] = await value + for (const [key, fetchPromise] of Object.entries(fetchPromises)) { + relationItems[key] = await fetchPromise } return relationItems diff --git a/packages/core/manifest/src/entity/tests/relationship.service.spec.ts b/packages/core/manifest/src/entity/tests/relationship.service.spec.ts index f6e9d0d4..a83acc0c 100644 --- a/packages/core/manifest/src/entity/tests/relationship.service.spec.ts +++ b/packages/core/manifest/src/entity/tests/relationship.service.spec.ts @@ -14,6 +14,7 @@ describe('RelationshipService', () => { entity: 'User', type: 'many-to-one' } + const dummyUserIds: number[] = [1, 2, 3] beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -30,7 +31,11 @@ describe('RelationshipService', () => { { provide: EntityService, useValue: { - getEntityRepository: jest.fn() + getEntityRepository: jest.fn(() => ({ + findOneBy: jest.fn(({ id }) => Promise.resolve({ id })), + findBy: jest.fn(() => dummyUserIds.map((id) => ({ id }))) + })), + getEntityMetadata: jest.fn(() => ({ target: 'Owner' })) } } ] @@ -73,4 +78,67 @@ describe('RelationshipService', () => { }) }) }) + + describe('fetchRelationItemsFromDto', () => { + it('should return an object with the relation items', async () => { + const itemDto = { + ownerId: 1 + } + + const relationItems = await service.fetchRelationItemsFromDto({ + itemDto, + relationships: [dummyRelationManifest] + }) + + expect(relationItems.owner['id']).toBe(itemDto.ownerId) + }) + + it('should retrun an object with the relation items for many-to-many relationships', async () => { + const manyToManyRelationManifest: RelationshipManifest = { + name: 'users', + entity: 'User', + type: 'many-to-many', + owningSide: true + } + + const itemDto = { + userIds: dummyUserIds + } + + const relationItems = (await service.fetchRelationItemsFromDto({ + itemDto, + relationships: [manyToManyRelationManifest] + })) as any + console.log(relationItems) + + expect(relationItems.users.length).toBe(3) + + relationItems.users.forEach((user, index) => { + expect(user['id']).toBe(itemDto.userIds[index]) + }) + }) + }) + + it('should empty missing relationships if emptyMissing is true', async () => { + const itemDto = {} + + const relationItems = await service.fetchRelationItemsFromDto({ + itemDto, + relationships: [dummyRelationManifest], + emptyMissing: true + }) + + expect(relationItems.owner).toBeNull() + }) + + it('should not empty missing relationships by default', async () => { + const itemDto = {} + + const relationItems = await service.fetchRelationItemsFromDto({ + itemDto, + relationships: [dummyRelationManifest] + }) + + expect(relationItems.owner).toBeUndefined() + }) }) diff --git a/packages/core/manifest/src/manifest/services/entity-manifest.service.ts b/packages/core/manifest/src/manifest/services/entity-manifest.service.ts index 7ef225c4..df0cf3e2 100644 --- a/packages/core/manifest/src/manifest/services/entity-manifest.service.ts +++ b/packages/core/manifest/src/manifest/services/entity-manifest.service.ts @@ -137,7 +137,9 @@ export class EntityManifestService { entitySchema.slug || slugify( dasherize( - pluralize.plural(entitySchema.className || className) + entitySchema.single + ? entitySchema.className || className + : pluralize.plural(entitySchema.className || className) ).toLowerCase() ), single: entitySchema.single || false, diff --git a/packages/core/manifest/src/manifest/services/relationship-manifest.service.ts b/packages/core/manifest/src/manifest/services/relationship-manifest.service.ts index 99887e15..8fc45cc6 100644 --- a/packages/core/manifest/src/manifest/services/relationship-manifest.service.ts +++ b/packages/core/manifest/src/manifest/services/relationship-manifest.service.ts @@ -145,7 +145,8 @@ export class RelationshipManifestService { otherEntityManifest.relationships.find( (relationship: RelationshipManifest) => relationship.entity === currentEntityManifest.className && - relationship.type === 'many-to-many' + relationship.type === 'many-to-many' && + relationship.owningSide === true ) if (oppositeRelationship) { diff --git a/packages/core/manifest/src/manifest/tests/relationship-manifest.service.spec.ts b/packages/core/manifest/src/manifest/tests/relationship-manifest.service.spec.ts index 61eb9ed2..a709ec44 100644 --- a/packages/core/manifest/src/manifest/tests/relationship-manifest.service.spec.ts +++ b/packages/core/manifest/src/manifest/tests/relationship-manifest.service.spec.ts @@ -33,7 +33,8 @@ describe('RelationshipManifestService', () => { { name: 'friends', entity: 'Dog', - type: 'many-to-many' + type: 'many-to-many', + owningSide: true } ], policies: { diff --git a/packages/core/manifest/src/open-api/services/open-api-crud.service.ts b/packages/core/manifest/src/open-api/services/open-api-crud.service.ts index f033cc04..506efb56 100644 --- a/packages/core/manifest/src/open-api/services/open-api-crud.service.ts +++ b/packages/core/manifest/src/open-api/services/open-api-crud.service.ts @@ -30,6 +30,7 @@ export class OpenApiCrudService { paths[`/api/collections/${entityManifest.slug}/{id}`] = { ...this.generateDetailPath(entityManifest), ...this.generateUpdatePath(entityManifest), + ...this.generatePatchPath(entityManifest), ...this.generateDeletePath(entityManifest) } }) @@ -40,7 +41,8 @@ export class OpenApiCrudService { .forEach((entityManifest: EntityManifest) => { paths[`/api/singles/${entityManifest.slug}`] = { ...this.generateDetailPath(entityManifest, true), - ...this.generateUpdatePath(entityManifest, true) + ...this.generateUpdatePath(entityManifest, true), + ...this.generatePatchPath(entityManifest, true) } }) @@ -266,8 +268,70 @@ export class OpenApiCrudService { ): PathItemObject { return { put: { - summary: `Update an existing ${entityManifest.nameSingular}`, - description: `Updates a single ${entityManifest.nameSingular} by its ID. The properties to update are passed in the request body as JSON.`, + summary: `Update an existing ${entityManifest.nameSingular} (full replace)`, + description: `Updates a single ${entityManifest.nameSingular} by its ID. The properties to update are passed in the request body as JSON. This operation fully replaces the entity and its relations. Leaving a property out will remove it.`, + tags: [ + upperCaseFirstLetter( + entityManifest.namePlural || entityManifest.nameSingular + ) + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object' + } + } + } + }, + parameters: single + ? [] + : [ + { + name: 'id', + in: 'path', + description: `The ID of the ${entityManifest.nameSingular}`, + required: true, + schema: { + type: 'integer' + } + } + ], + responses: { + '200': { + description: `OK`, + content: { + 'application/json': { + schema: { + type: 'object' + } + } + } + }, + '404': { + description: `Not found` + } + } + } + } + } + + /** + * Generates the path for partially updating an entity. + * + * @param entityManifest The entity manifest. + * @param single Whether the entity is a single entity (defaults to false -> collection). + * + * @returns The path item object. + */ + generatePatchPath( + entityManifest: EntityManifest, + single?: boolean + ): PathItemObject { + return { + patch: { + summary: `Update an existing ${entityManifest.nameSingular} (partial update)`, + description: `Updates a single ${entityManifest.nameSingular} by its ID. The properties to update are passed in the request body as JSON. This operation partially updates the entity and its relations. Leaving a property out will not remove it.`, tags: [ upperCaseFirstLetter( entityManifest.namePlural || entityManifest.nameSingular diff --git a/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts b/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts index bfb8db2d..ac4635fc 100644 --- a/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts +++ b/packages/core/manifest/src/open-api/tests/open-api-crud.service.spec.ts @@ -41,12 +41,13 @@ describe('OpenApiCrudService', () => { expect(service).toBeDefined() }) - it('should generate all 6 entity paths', () => { + it('should generate all 7 entity paths', () => { jest.spyOn(service, 'generateListPath').mockReturnValue({}) jest.spyOn(service, 'generateListSelectOptionsPath').mockReturnValue({}) jest.spyOn(service, 'generateCreatePath').mockReturnValue({}) jest.spyOn(service, 'generateDetailPath').mockReturnValue({}) jest.spyOn(service, 'generateUpdatePath').mockReturnValue({}) + jest.spyOn(service, 'generatePatchPath').mockReturnValue({}) jest.spyOn(service, 'generateDeletePath').mockReturnValue({}) const paths = service.generateEntityPaths([dummyEntityManifest]) @@ -59,6 +60,7 @@ describe('OpenApiCrudService', () => { expect(service.generateCreatePath).toHaveBeenCalled() expect(service.generateDetailPath).toHaveBeenCalled() expect(service.generateUpdatePath).toHaveBeenCalled() + expect(service.generatePatchPath).toHaveBeenCalled() expect(service.generateDeletePath).toHaveBeenCalled() }) }) diff --git a/packages/core/manifest/src/validation/tests/custom-validators.spec.ts b/packages/core/manifest/src/validation/tests/custom-validators.spec.ts index 295faeab..fc6d72fc 100644 --- a/packages/core/manifest/src/validation/tests/custom-validators.spec.ts +++ b/packages/core/manifest/src/validation/tests/custom-validators.spec.ts @@ -61,8 +61,6 @@ describe('Custom validators', () => { }) ) - console.log(goodValidations, badValidations) - expect(goodValidations.every((validation) => validation.length === 0)).toBe( true ) @@ -91,8 +89,6 @@ describe('Custom validators', () => { }) ) - console.log(goodValidations, badValidations) - expect(goodValidations.every((validation) => validation.length === 0)).toBe( true ) diff --git a/packages/js-sdk/src/Manifest.ts b/packages/js-sdk/src/Manifest.ts index a53164e8..f65c0f3f 100644 --- a/packages/js-sdk/src/Manifest.ts +++ b/packages/js-sdk/src/Manifest.ts @@ -149,7 +149,7 @@ export default class Manifest { } /** - * Update an item of the entity. + * Update an item of the entity doing a full replace. Leaving blank fields and relations will remove them. Use patch for partial updates. * * @param id The id of the item to update. * @param itemDto The DTO of the item to update. @@ -165,6 +165,23 @@ export default class Manifest { }) as Promise } + /** + * Partially update an item of the entity. Leaving blank fields and relations will not remove them. Use update for full replaces. + * + * @param id The id of the item to update. + * @param itemDto The DTO of the item to update. + * + * @returns The updated item. + * @example client.from('cats').update(1, { name: 'updated name' }); + */ + async patch(id: number, itemDto: unknown): Promise { + return this.fetch({ + path: `/collections/${this.slug}/${id}`, + method: 'PATCH', + body: itemDto + }) as Promise + } + /** * * Delete an item of the entity. @@ -337,14 +354,14 @@ export default class Manifest { }) as Promise<{ email: string }> } - private fetch({ + private async fetch({ path, method, body, queryParams }: { path: string - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown queryParams?: Record }): Promise { diff --git a/packages/js-sdk/tests/crud.spec.ts b/packages/js-sdk/tests/crud.spec.ts index 275ac1f2..9f6409e8 100644 --- a/packages/js-sdk/tests/crud.spec.ts +++ b/packages/js-sdk/tests/crud.spec.ts @@ -164,6 +164,28 @@ describe('CRUD operations', () => { expect(item).toMatchObject(dummyItem) }) + it('should patch an item', async () => { + const id: number = 1 + + fetchMock.mock( + { + url: `${collectionBaseUrl}/cats/${id}`, + method: 'PATCH', + body: { + name: 'Tom' + } + }, + dummyItem + ) + + const manifest = new Manifest() + const item = await manifest.from('cats').patch(id, { + name: 'Tom' + }) + + expect(item).toMatchObject(dummyItem) + }) + it('should delete an item', async () => { const id: number = 1