diff --git a/docs/content/1.guide/4.repository/4.updating-data.md b/docs/content/1.guide/4.repository/4.updating-data.md index 132742b20..d30f83e0d 100644 --- a/docs/content/1.guide/4.repository/4.updating-data.md +++ b/docs/content/1.guide/4.repository/4.updating-data.md @@ -62,3 +62,21 @@ useRepo(Flight) ``` As opposed to updating records by the `save` method, it only accepts an object as the argument (not an array). Also, it will not normalize the data, and any nested relationships will be ignored. + +## Update Or Create + +The `updateOrCreate` method updates the first record matching the attributes given as the first argument with the values given as the second argument. If no record matches, a new record is created from the merged attributes and values. This works like Laravel's `updateOrCreate`. + +```js +// Update the age of the first user named "Jane Doe", +// or create a new user with that name and age 41. +useRepo(User).updateOrCreate({ name: 'Jane Doe' }, { age: 41 }) +``` + +If you only need to create the record when it doesn't exist yet — without touching an existing one — use `firstOrCreate` instead. It returns the first record matching the attributes, or creates one from the merged attributes and values. + +```js +// Returns the existing "Jane Doe" unchanged, +// or creates her with age 40. +const user = useRepo(User).firstOrCreate({ name: 'Jane Doe' }, { age: 40 }) +``` diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index 8d4bd7617..ebee567f8 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -423,6 +423,41 @@ export class Repository { return this.query().save(records) } + /** + * Get the first record matching the given attributes or persist a new + * record made from the merged attributes and values. + */ + firstOrCreate (attributes: Element, values: Element = {}): M { + const record = this.matching(attributes) + + return record ?? this.save({ ...attributes, ...values }) + } + + /** + * Update the first record matching the given attributes with the given + * values or persist a new record made from the merged attributes and values. + */ + updateOrCreate (attributes: Element, values: Element = {}): M { + const record = this.matching(attributes) + + if (!record) { return this.save({ ...attributes, ...values }) } + + const primaryKey = this.getModel().$primaryKey() + const keyValues = (isArray(primaryKey) ? primaryKey : [primaryKey]).reduce((keys, key) => { + keys[key] = record[key as keyof M] as any + return keys + }, {}) + + return this.save({ ...keyValues, ...values }) + } + + /** + * Get the first record matching the given attributes. + */ + protected matching (attributes: Element): Item { + return this.query().where((model: M) => Object.entries(attributes).every(([field, value]) => model[field as keyof M] === value)).first() + } + /** * Create and persist model with default values. */ diff --git a/packages/pinia-orm/tests/feature/repository/first_or_create.spec.ts b/packages/pinia-orm/tests/feature/repository/first_or_create.spec.ts new file mode 100644 index 000000000..e08ff882d --- /dev/null +++ b/packages/pinia-orm/tests/feature/repository/first_or_create.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { Model, useRepo } from '../../../src' +import { Attr, Num, Str } from '../../../src/decorators' +import { assertState } from '../../helpers' + +describe('feature/repository/first_or_create', () => { + class User extends Model { + static entity = 'users' + + @Attr() id!: any + @Str('') name!: string + @Num(0) age!: number + } + + it('returns the first record matching the attributes', () => { + const userRepo = useRepo(User) + + userRepo.save([ + { id: 1, name: 'John Doe', age: 30 }, + { id: 2, name: 'Jane Doe', age: 40 }, + ]) + + const user = userRepo.firstOrCreate({ name: 'Jane Doe' }, { age: 50 }) + + expect(user.id).toBe(2) + expect(user.age).toBe(40) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe', age: 30 }, + 2: { id: 2, name: 'Jane Doe', age: 40 }, + }, + }) + }) + + it('creates a new record from the merged attributes and values if none matches', () => { + const userRepo = useRepo(User) + + userRepo.save({ id: 1, name: 'John Doe', age: 30 }) + + const user = userRepo.firstOrCreate({ id: 2, name: 'Jane Doe' }, { age: 40 }) + + expect(user.id).toBe(2) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe', age: 30 }, + 2: { id: 2, name: 'Jane Doe', age: 40 }, + }, + }) + }) +}) diff --git a/packages/pinia-orm/tests/feature/repository/update_or_create.spec.ts b/packages/pinia-orm/tests/feature/repository/update_or_create.spec.ts new file mode 100644 index 000000000..db8d9ae29 --- /dev/null +++ b/packages/pinia-orm/tests/feature/repository/update_or_create.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import { Model, useRepo } from '../../../src' +import { Attr, Num, Str, Uid } from '../../../src/decorators' +import { assertState } from '../../helpers' + +describe('feature/repository/update_or_create', () => { + class User extends Model { + static entity = 'users' + + @Attr() id!: any + @Str('') name!: string + @Num(0) age!: number + } + + it('creates a new record if no record matches the attributes', () => { + const userRepo = useRepo(User) + + userRepo.save({ id: 1, name: 'John Doe', age: 30 }) + + const user = userRepo.updateOrCreate({ id: 2 }, { name: 'Jane Doe', age: 40 }) + + expect(user.id).toBe(2) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe', age: 30 }, + 2: { id: 2, name: 'Jane Doe', age: 40 }, + }, + }) + }) + + it('updates the matching record with the given values', () => { + const userRepo = useRepo(User) + + userRepo.save([ + { id: 1, name: 'John Doe', age: 30 }, + { id: 2, name: 'Jane Doe', age: 40 }, + ]) + + const user = userRepo.updateOrCreate({ name: 'Jane Doe' }, { age: 41 }) + + expect(user.id).toBe(2) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe', age: 30 }, + 2: { id: 2, name: 'Jane Doe', age: 41 }, + }, + }) + }) + + it('matches records by multiple attributes', () => { + const userRepo = useRepo(User) + + userRepo.save([ + { id: 1, name: 'John Doe', age: 30 }, + { id: 2, name: 'John Doe', age: 40 }, + ]) + + userRepo.updateOrCreate({ name: 'John Doe', age: 40 }, { name: 'Johnny Doe' }) + + assertState({ + users: { + 1: { id: 1, name: 'John Doe', age: 30 }, + 2: { id: 2, name: 'Johnny Doe', age: 40 }, + }, + }) + }) + + it('updates records with a composite primary key', () => { + class RoleUser extends Model { + static entity = 'roleUser' + + static primaryKey = ['role_id', 'user_id'] + + @Attr(null) role_id!: number | null + @Attr(null) user_id!: number | null + @Num(0) level!: number + } + + const roleUserRepo = useRepo(RoleUser) + + roleUserRepo.save([ + { role_id: 1, user_id: 1, level: 1 }, + { role_id: 2, user_id: 1, level: 2 }, + ]) + + roleUserRepo.updateOrCreate({ role_id: 2, user_id: 1 }, { level: 5 }) + + assertState({ + roleUser: { + '[2,1]': { role_id: 2, user_id: 1, level: 5 }, + '[1,1]': { role_id: 1, user_id: 1, level: 1 }, + }, + }) + }) + + it('creates a record with a generated uid', () => { + class Tag extends Model { + static entity = 'tags' + + @Uid() id!: string + @Str('') name!: string + } + + const tagRepo = useRepo(Tag) + + const tag = tagRepo.updateOrCreate({ name: 'news' }) + + expect(tag.id).not.toBeNull() + expect(tagRepo.all().length).toBe(1) + }) +})