Skip to content

Commit

Permalink
feat: support composite key (#23)
Browse files Browse the repository at this point in the history
Co-authored-by: Cue <1493221+cuebit@users.noreply.github.com>
  • Loading branch information
kiaking and cuebit committed May 19, 2020
1 parent 8a3b729 commit e6208e9
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 17 deletions.
83 changes: 75 additions & 8 deletions src/model/Model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class Model {
/**
* The primary key for the model.
*/
static primaryKey: string = 'id'
static primaryKey: string | string[] = 'id'

/**
* The schema for the model. It contains the result of the `fields`
Expand Down Expand Up @@ -183,7 +183,7 @@ export class Model {
): HasOne {
const model = this.newRawInstance()

localKey = localKey ?? model.$getKeyName()
localKey = localKey ?? model.$getLocalKey()

return new HasOne(model, related.newRawInstance(), foreignKey, localKey)
}
Expand All @@ -198,7 +198,7 @@ export class Model {
): BelongsTo {
const instance = related.newRawInstance()

ownerKey = ownerKey ?? instance.$getKeyName()
ownerKey = ownerKey ?? instance.$getLocalKey()

return new BelongsTo(this.newRawInstance(), instance, foreignKey, ownerKey)
}
Expand All @@ -213,7 +213,7 @@ export class Model {
): HasMany {
const model = this.newRawInstance()

localKey = localKey ?? model.$getKeyName()
localKey = localKey ?? model.$getLocalKey()

return new HasMany(model, related.newRawInstance(), foreignKey, localKey)
}
Expand Down Expand Up @@ -248,7 +248,7 @@ export class Model {
/**
* Get the primary key for this model.
*/
get $primaryKey(): string {
get $primaryKey(): string | string[] {
return this.$self.primaryKey
}

Expand Down Expand Up @@ -340,18 +340,85 @@ export class Model {
/**
* Get the primary key field name.
*/
$getKeyName(): string {
$getKeyName(): string | string[] {
return this.$primaryKey
}

/**
* Get primary key value for the model. If the model has the composite key,
* it will return an array of ids.
*/
$getKey(record?: Element): string | number | (string | number)[] | null {
record = record ?? this

if (this.$hasCompositeKey()) {
return this.$getCompositeKey(record)
}

const id = record[this.$getKeyName() as string]

return isNullish(id) ? null : id
}

/**
* Check whether the model has composite key.
*/
$hasCompositeKey(): boolean {
return isArray(this.$getKeyName())
}

/**
* Get the composite key values for the given model as an array of ids.
*/
protected $getCompositeKey(record: Element): (string | number)[] | null {
let ids = [] as (string | number)[] | null

;(this.$getKeyName() as string[]).every((key) => {
const id = record[key]

if (isNullish(id)) {
ids = null
return false
}

;(ids as (string | number)[]).push(id)
return true
})

return ids === null ? null : ids
}

/**
* Get the index id of this model or for a given record.
*/
$getIndexId(record?: Element): string | null {
const target = record ?? this
const id = target[this.$primaryKey]

return isNullish(id) ? null : String(id)
const id = this.$getKey(target)

return id === null ? null : this.$stringifyId(id)
}

/**
* Stringify the given id.
*/
protected $stringifyId(id: string | number | (string | number)[]): string {
return isArray(id) ? JSON.stringify(id) : String(id)
}

/**
* Get the local key name for the model.
*/
$getLocalKey(): string {
// If the model has a composite key, we can't use it as a local key for the
// relation. The user must provide the key name explicitly, so we'll throw
// an error here.
assert(!this.$hasCompositeKey(), [
'Please provide the local key for the relationship. The model with the',
"composite key can't infer its local key."
])

return this.$getKeyName() as string
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/query/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
isFunction,
isEmpty,
orderBy,
groupBy
groupBy,
assert
} from '../support/Utils'
import {
Element,
Expand Down Expand Up @@ -575,6 +576,11 @@ export class Query<M extends Model = Model> {
async destroy(id: string | number): Promise<Item<M>>
async destroy(ids: (string | number)[]): Promise<Collection<M>>
async destroy(ids: any): Promise<any> {
assert(!this.model.$hasCompositeKey(), [
"You can't use the `destroy` method on a model with a composite key.",
'Please use `delete` method instead.'
])

if (isArray(ids)) {
return this.destroyMany(ids)
}
Expand All @@ -590,7 +596,7 @@ export class Query<M extends Model = Model> {
}

/**
* Delete records that match the query chain.
* Delete records resolved by the query chain.
*/
async delete(): Promise<Collection<M>> {
const models = this.get()
Expand Down
20 changes: 13 additions & 7 deletions src/schema/Schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { schema as Normalizr, Schema as NormalizrSchema } from 'normalizr'
import { isNullish, assert } from '../support/Utils'
import { isNullish, isArray, assert } from '../support/Utils'
import { Uid } from '../model/attributes/types/Uid'
import { Relation } from '../model/attributes/relations/Relation'
import { Model } from '../model/Model'
Expand Down Expand Up @@ -115,7 +115,7 @@ export class Schema {
// index id.
const indexId = model.$getIndexId(record)

assert(!isNullish(indexId), [
assert(indexId !== null, [
'The record is missing the primary key. If you want to persist record',
'without the primary key, please defined the primary key field as',
'`uid` field.'
Expand All @@ -129,14 +129,20 @@ export class Schema {
* Get all primary keys defined by the Uid attribute for the given model.
*/
private getUidPrimaryKeyPairs(model: Model): Record<string, Uid> {
const key = model.$getKeyName()
const keys = isArray(key) ? key : [key]

const attributes = {} as Record<string, Uid>

const key = model.$getKeyName()
const attr = model.$fields[key]
keys.forEach((k) => {
const attr = model.$fields[k]

if (attr instanceof Uid) {
attributes[key] = attr
}
if (attr instanceof Uid) {
attributes[k] = attr
}

model.$fields[k]
})

return attributes
}
Expand Down
34 changes: 34 additions & 0 deletions test/feature/repository/deletes_destroy_composite_key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createStore, fillState } from 'test/Helpers'
import { Model, Attr, Str } from '@/index'

describe('feature/repository/deletes_destroy_composite_key', () => {
class User extends Model {
static entity = 'users'

static primaryKey = ['idA', 'idB']

@Attr() idA!: any
@Attr() idB!: any
@Str('') name!: string
}

it('throws if the model has composite key', async () => {
expect.assertions(1)

const store = createStore()

fillState(store, {
users: {
1: { id: 1, name: 'John Doe' },
2: { id: 2, name: 'Jane Doe' },
3: { id: 3, name: 'Johnny Doe' }
}
})

try {
await store.$repo(User).destroy(2)
} catch (e) {
expect(e).toBeInstanceOf(Error)
}
})
})
42 changes: 42 additions & 0 deletions test/feature/repository/inserts_fresh_composite_key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createStore, assertState } from 'test/Helpers'
import { Model, Attr, Str } from '@/index'

describe('feature/repository/inserts_fresh_composite_key', () => {
class User extends Model {
static entity = 'users'

static primaryKey = ['idA', 'idB']

@Attr() idA!: any
@Attr() idB!: any
@Str('') name!: string
}

it('inserts records with the composite key', async () => {
const store = createStore()

await store.$repo(User).fresh([
{ idA: 1, idB: 2, name: 'John Doe' },
{ idA: 2, idB: 1, name: 'Jane Doe' }
])

assertState(store, {
users: {
'[1,2]': { idA: 1, idB: 2, name: 'John Doe' },
'[2,1]': { idA: 2, idB: 1, name: 'Jane Doe' }
}
})
})

it('throws if the one of the composite key is missing', async () => {
expect.assertions(1)

const store = createStore()

try {
await store.$repo(User).insert({ idA: 1, name: 'John Doe' })
} catch (e) {
expect(e).toBeInstanceOf(Error)
}
})
})
42 changes: 42 additions & 0 deletions test/feature/repository/inserts_insert_composite_key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createStore, assertState } from 'test/Helpers'
import { Model, Attr, Str } from '@/index'

describe('feature/repository/inserts_insert_composite_key', () => {
class User extends Model {
static entity = 'users'

static primaryKey = ['idA', 'idB']

@Attr() idA!: any
@Attr() idB!: any
@Str('') name!: string
}

it('inserts records with the composite key', async () => {
const store = createStore()

await store.$repo(User).insert([
{ idA: 1, idB: 2, name: 'John Doe' },
{ idA: 2, idB: 1, name: 'Jane Doe' }
])

assertState(store, {
users: {
'[1,2]': { idA: 1, idB: 2, name: 'John Doe' },
'[2,1]': { idA: 2, idB: 1, name: 'Jane Doe' }
}
})
})

it('throws if the one of the composite key is missing', async () => {
expect.assertions(1)

const store = createStore()

try {
await store.$repo(User).insert({ idA: 1, name: 'John Doe' })
} catch (e) {
expect(e).toBeInstanceOf(Error)
}
})
})
54 changes: 54 additions & 0 deletions test/feature/repository/updates_update_composite_key.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createStore, fillState, assertState } from 'test/Helpers'
import { Model, Attr, Str, Num } from '@/index'

describe('feature/repository/updates_update_composite_key', () => {
class User extends Model {
static entity = 'users'

static primaryKey = ['idA', 'idB']

@Attr() idA!: any
@Attr() idB!: any
@Str('') name!: string
@Num(0) age!: number
}

it('update records with composite key', async () => {
const store = createStore()

fillState(store, {
users: {
'[1,2]': { idA: 1, idB: 2, name: 'John Doe', age: 40 },
'[2,1]': { idA: 2, idB: 1, name: 'Jane Doe', age: 30 }
}
})

await store.$repo(User).update({ idA: 2, idB: 1, age: 50 })

assertState(store, {
users: {
'[1,2]': { idA: 1, idB: 2, name: 'John Doe', age: 40 },
'[2,1]': { idA: 2, idB: 1, name: 'Jane Doe', age: 50 }
}
})
})

it('throws if the one of the composite key is missing', async () => {
expect.assertions(1)

const store = createStore()

fillState(store, {
users: {
'[1,2]': { idA: 1, idB: 2, name: 'John Doe', age: 40 },
'[2,1]': { idA: 2, idB: 1, name: 'Jane Doe', age: 30 }
}
})

try {
await store.$repo(User).update({ idA: 2, age: 50 })
} catch (e) {
expect(e).toBeInstanceOf(Error)
}
})
})

0 comments on commit e6208e9

Please sign in to comment.