From 8ff30ad626a39963fafeaae89ac38ef62fd59d7e Mon Sep 17 00:00:00 2001 From: Tim Kolotov Date: Mon, 12 Jun 2023 09:49:42 +0300 Subject: [PATCH] feat: make oneOf relation required if non-nullable --- .gitignore | 3 +- src/glossary.ts | 2 +- src/model/defineRelationalProperties.ts | 38 ++++--- test/model/create.test.ts | 21 +++- test/model/relationalProperties.test-d.ts | 4 +- test/model/relationalProperties.test.ts | 3 +- test/relations/bi-directional.test.ts | 14 +-- test/relations/many-to-one.test.ts | 5 +- test/relations/one-to-one.create.test.ts | 51 ++------- test/relations/one-to-one.operations.test.ts | 26 ----- test/relations/one-to-one.update.test.ts | 105 ------------------- 11 files changed, 70 insertions(+), 202 deletions(-) diff --git a/.gitignore b/.gitignore index 611ff96e..ff494b88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules lib -*.log \ No newline at end of file +*.log +.idea \ No newline at end of file diff --git a/src/glossary.ts b/src/glossary.ts index d26d8ee8..22f17316 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -229,7 +229,7 @@ export type Value< Target[Key] extends OneOf ? Nullable extends true ? PublicEntity | null - : PublicEntity | undefined + : PublicEntity : // Extract value type from ManyOf relations. Target[Key] extends ManyOf ? Nullable extends true diff --git a/src/model/defineRelationalProperties.ts b/src/model/defineRelationalProperties.ts index f8893f88..e5420157 100644 --- a/src/model/defineRelationalProperties.ts +++ b/src/model/defineRelationalProperties.ts @@ -9,10 +9,21 @@ import { PRIMARY_KEY, Value, } from '../glossary' -import { RelationKind, RelationsList } from '../relations/Relation' +import { Relation, RelationKind, RelationsList } from '../relations/Relation' const log = debug('defineRelationalProperties') +const getInvariantMessagePrefix = ( + entity: Entity, + relation: Relation, + propertyPath: string[], +) => + `Failed to define a "${relation.kind}" relationship to "${ + relation.target.modelName + }" at "${entity[ENTITY_TYPE]}.${propertyPath.join('.')}" (${ + entity[PRIMARY_KEY] + }: "${entity[entity[PRIMARY_KEY]]}")` + export function defineRelationalProperties( entity: Entity, initialValues: Partial>, @@ -23,12 +34,15 @@ export function defineRelationalProperties( log('defining relational properties...', { entity, initialValues, relations }) for (const { propertyPath, relation } of relations) { + const invariantMessagePrefix = getInvariantMessagePrefix( + entity, + relation, + propertyPath, + ) + invariant( dictionary[relation.target.modelName], - 'Failed to define a "%s" relational property to "%s" on "%s": cannot find a model by the name "%s".', - relation.kind, - propertyPath.join('.'), - entity[ENTITY_TYPE], + `${invariantMessagePrefix}: cannot find a model by the name "%s".`, relation.target.modelName, ) @@ -39,14 +53,14 @@ export function defineRelationalProperties( invariant( references !== null || relation.attributes.nullable, - 'Failed to define a "%s" relationship to "%s" at "%s.%s" (%s: "%s"): cannot set a non-nullable relationship to null.', + `${invariantMessagePrefix}: cannot set a non-nullable relationship to null.`, + ) - relation.kind, - relation.target.modelName, - entity[ENTITY_TYPE], - propertyPath.join('.'), - entity[PRIMARY_KEY], - entity[entity[PRIMARY_KEY]], + invariant( + relation.kind !== RelationKind.OneOf || + references !== undefined || + relation.attributes.nullable, + `${invariantMessagePrefix}: a value must be provided for a non-nullable relationship.`, ) log( diff --git a/test/model/create.test.ts b/test/model/create.test.ts index e53b0a91..0b1292c4 100644 --- a/test/model/create.test.ts +++ b/test/model/create.test.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker' -import { NullableObject, NullableProperty } from '../../src/nullable' import { factory, primaryKey, oneOf, manyOf, nullable } from '../../src' import { identity } from '../../src/utils/identity' @@ -709,3 +708,23 @@ describe('nullable objects with complex structure and null definition', () => { }) }) }) + +test('throws an exception when value is not provided for a non-nullable oneOf relation', () => { + const db = factory({ + user: { + id: primaryKey(String), + city: oneOf('city'), + }, + city: { + id: primaryKey(String), + }, + }) + + expect(() => { + db.user.create({ + id: 'user-1', + }) + }).toThrowError( + 'Failed to define a "ONE_OF" relationship to "city" at "user.city" (id: "user-1"): a value must be provided for a non-nullable relationship.', + ) +}) diff --git a/test/model/relationalProperties.test-d.ts b/test/model/relationalProperties.test-d.ts index 1fe10ac0..eba5ecd8 100644 --- a/test/model/relationalProperties.test-d.ts +++ b/test/model/relationalProperties.test-d.ts @@ -8,7 +8,7 @@ const db = factory({ post: { id: primaryKey(String), text: String, - author: oneOf('user'), + author: nullable(oneOf('user')), reply: nullable(oneOf('post')), likedBy: nullable(manyOf('user')), }, @@ -17,7 +17,7 @@ const db = factory({ const user = db.user.create() const post = db.post.create() -// @ts-expect-error author is potentially undefined +// @ts-expect-error author is potentially null post.author.id // @ts-expect-error reply is potentially null diff --git a/test/model/relationalProperties.test.ts b/test/model/relationalProperties.test.ts index 02e01d93..4fc54753 100644 --- a/test/model/relationalProperties.test.ts +++ b/test/model/relationalProperties.test.ts @@ -5,7 +5,7 @@ import { RelationsList, } from '../../src/relations/Relation' import { defineRelationalProperties } from '../../src/model/defineRelationalProperties' -import { testFactory } from '../../test/testUtils' +import { testFactory } from '../testUtils' it('marks relational properties as enumerable', () => { const { db, dictionary, databaseInstance } = testFactory({ @@ -27,6 +27,7 @@ it('marks relational properties as enumerable', () => { const post = db.post.create({ id: 'post-1', title: 'Test Post', + author: user, }) const relations: RelationsList = [ diff --git a/test/relations/bi-directional.test.ts b/test/relations/bi-directional.test.ts index b00aa150..ea60a02d 100644 --- a/test/relations/bi-directional.test.ts +++ b/test/relations/bi-directional.test.ts @@ -1,13 +1,13 @@ /** * @see https://github.com/mswjs/data/issues/139 */ -import { factory, manyOf, oneOf, primaryKey } from '@mswjs/data' +import { factory, manyOf, oneOf, primaryKey, nullable } from '@mswjs/data' test('supports creating a bi-directional one-to-one relationship', () => { const db = factory({ user: { id: primaryKey(String), - partner: oneOf('user'), + partner: nullable(oneOf('user')), }, }) @@ -117,7 +117,7 @@ test('supports querying by a bi-directional one-to-one relationship', () => { const db = factory({ user: { id: primaryKey(String), - partner: oneOf('user'), + partner: nullable(oneOf('user')) }, }) @@ -202,8 +202,8 @@ test('supports querying by a bi-directional one-to-many relationship', () => { }) // Create unrelated user and post to ensure they are not included. - db.user.create({ id: 'user-unrelated' }) - db.post.create({ id: 'post-unrelated' }) + const unrelatedUser = db.user.create({ id: 'user-unrelated' }) + db.post.create({ id: 'post-unrelated', author: unrelatedUser }) // Find posts in one-to-many direction const posts = db.post.findMany({ @@ -304,7 +304,7 @@ test('supports updating using an entity with a bi-directional one-to-one relatio const db = factory({ user: { id: primaryKey(String), - partner: oneOf('user'), + partner: nullable(oneOf('user')), }, }) @@ -343,7 +343,7 @@ test('supports updating using an entity with a bi-directional one-to-many relati post: { id: primaryKey(String), title: String, - author: oneOf('user'), + author: nullable(oneOf('user')), }, }) diff --git a/test/relations/many-to-one.test.ts b/test/relations/many-to-one.test.ts index 4adef6b5..63536172 100644 --- a/test/relations/many-to-one.test.ts +++ b/test/relations/many-to-one.test.ts @@ -325,7 +325,7 @@ test('updates a many-to-one relational property without initial value', () => { }, post: { id: primaryKey(String), - author: oneOf('user'), + author: nullable(oneOf('user')), }, }) @@ -423,7 +423,7 @@ test('does not throw any error when a many-to-one entity is created without a re post: { id: primaryKey(String), title: String, - author: oneOf('user'), + author: nullable(oneOf('user')), }, }) @@ -437,6 +437,7 @@ test('does not throw any error when a many-to-one entity is created without a re [PRIMARY_KEY]: 'id', id: 'post-1', title: 'First post', + author: null, }) }) diff --git a/test/relations/one-to-one.create.test.ts b/test/relations/one-to-one.create.test.ts index d4e3f0bf..4f65cc30 100644 --- a/test/relations/one-to-one.create.test.ts +++ b/test/relations/one-to-one.create.test.ts @@ -231,7 +231,7 @@ it('creates a non-nullable relationship', () => { ).toEqual(expectedCountry) }) -it('creates a non-nullable relationship without the initial value', () => { +it('forbids creating a non-nullable relationship without the initial value', () => { const { db, entity } = testFactory({ country: { code: primaryKey(String), @@ -242,24 +242,13 @@ it('creates a non-nullable relationship without the initial value', () => { }, }) - const country = db.country.create({ - code: 'uk', - }) - - const expectedCountry = entity('country', { - code: 'uk', - capital: undefined, - }) - - expect(country).toEqual(expectedCountry) - expect(db.country.findFirst({ where: { code: { equals: 'uk' } } })).toEqual( - expectedCountry, + expect(() => + db.country.create({ + code: 'uk', + }) + ).toThrow( + 'Failed to define a "ONE_OF" relationship to "city" at "country.capital" (code: "uk"): a value must be provided for a non-nullable relationship.', ) - expect( - db.country.findFirst({ - where: { capital: { name: { equals: 'Manchester' } } }, - }), - ).toEqual(null) }) it('forbids creating a non-nullable relationship with null as initial value', () => { @@ -350,32 +339,6 @@ it('creates a non-nullable unique relationship with initial value', () => { ).toEqual(expectedCountry) }) -it('creates a non-nullable unique relationship without initial value', () => { - const { db, entity } = testFactory({ - country: { - code: primaryKey(String), - capital: oneOf('city', { unique: true }), - }, - city: { - name: primaryKey(String), - }, - }) - - const country = db.country.create({ - code: 'uk', - }) - - const expectedCountry = entity('country', { - code: 'uk', - capital: undefined, - }) - - expect(country).toEqual(expectedCountry) - expect(db.country.findFirst({ where: { code: { equals: 'uk' } } })).toEqual( - expectedCountry, - ) -}) - it('forbids creating a unique relationship to already referenced entity', () => { const { db } = testFactory({ country: { diff --git a/test/relations/one-to-one.operations.test.ts b/test/relations/one-to-one.operations.test.ts index a50fb009..8ed79bf8 100644 --- a/test/relations/one-to-one.operations.test.ts +++ b/test/relations/one-to-one.operations.test.ts @@ -53,32 +53,6 @@ it('supports querying through a non-nullable relationship with initial value', ( ).toEqual(null) }) -it('supports querying through a non-nullable relationship without initial value', () => { - const { db } = testFactory({ - country: { - code: primaryKey(String), - capital: oneOf('city'), - }, - city: { - name: primaryKey(String), - }, - }) - - db.country.create({ - code: 'uk', - }) - - // Querying through the relationship is permitted - // but since it hasn't been set, no queries will match. - expect( - db.country.findFirst({ - where: { - capital: { name: { equals: 'London' } }, - }, - }), - ).toEqual(null) -}) - it('supports querying through a deeply nested non-nullable relationship', () => { const { db, entity } = testFactory({ user: { diff --git a/test/relations/one-to-one.update.test.ts b/test/relations/one-to-one.update.test.ts index 2e90a2e8..52fafda3 100644 --- a/test/relations/one-to-one.update.test.ts +++ b/test/relations/one-to-one.update.test.ts @@ -338,46 +338,6 @@ it('updates a non-nullable relationship with initial value to a new entity', () ) }) -it('updates a non-nullable relationship without initial value to a new entity', () => { - const { db, entity } = testFactory({ - country: { - code: primaryKey(String), - capital: oneOf('city'), - }, - city: { - name: primaryKey(String), - }, - }) - - db.country.create({ code: 'uk' }) - - const nextCapital = db.city.create({ name: 'Leads' }) - const nextCountry = db.country.update({ - where: { code: { equals: 'uk' } }, - data: { capital: nextCapital }, - }) - - const expectedCountry = entity('country', { - code: 'uk', - capital: nextCapital, - }) - - expect(nextCountry).toHaveRelationalProperty('capital', nextCapital) - - expect(nextCountry).toEqual(expectedCountry) - expect(db.country.findFirst({ where: { code: { equals: 'uk' } } })).toEqual( - expectedCountry, - ) - expect( - db.country.findFirst({ where: { capital: { name: { equals: 'Leads' } } } }), - ).toEqual(expectedCountry) - - // Newly referenced entity is created. - expect(db.city.findFirst({ where: { name: { equals: 'Leads' } } })).toEqual( - nextCapital, - ) -}) - it('preserves the relational property after arbitrary parent entity update', () => { const { db } = testFactory({ country: { @@ -412,71 +372,6 @@ it('preserves the relational property after arbitrary parent entity update', () ).toHaveRelationalProperty('capital', london) }) -it('forbids updating a non-nullable relationship without initial value to null', () => { - const { db } = testFactory({ - country: { - code: primaryKey(String), - capital: oneOf('city'), - }, - city: { - name: primaryKey(String), - }, - }) - - const country = db.country.create({ code: 'uk' }) - - expect(() => - db.country.update({ - where: { code: { equals: 'uk' } }, - data: { - // @ts-expect-error Runtime value incompatibility. - capital: null, - }, - }), - ).toThrow( - 'Failed to update a "ONE_OF" relationship to "city" at "country.capital" (code: "uk"): cannot update a non-nullable relationship to null.', - ) - // Non-nullable relationships are not instantiated without a value. - expect(country).not.toHaveProperty('capital') -}) - -it('forbids updating a non-nullable relationship without initial value to a different model', () => { - const { db } = testFactory({ - country: { - code: primaryKey(String), - capital: oneOf('city'), - }, - city: { - name: primaryKey(String), - }, - user: { - id: primaryKey(String), - }, - }) - - const country = db.country.create({ code: 'uk' }) - - expect(() => - db.country.update({ - where: { code: { equals: 'uk' } }, - data: { - // @ts-expect-error Runtime value incompatibility. - capital: db.user.create({ id: 'user-1' }), - }, - }), - ).toThrow( - 'Failed to update a "ONE_OF" relationship to "city" at "country.capital" (code: "uk"): expected the next value to reference a "city" but got "user" (id: "user-1").', - ) - expect(country).not.toHaveProperty('capital') - expect(db.user.getAll()).toEqual([ - { - [ENTITY_TYPE]: 'user', - [PRIMARY_KEY]: 'id', - id: 'user-1', - }, - ]) -}) - it('forbids updating a unique non-nullable relationship to already referenced entity', () => { const { db } = testFactory({ country: {