Skip to content

Commit 5af9d3a

Browse files
authored
Merge pull request #70 from jannishuebl/allow_to_use_dasherized_keys
add basic design for allowing to set letterCase
2 parents 54f6612 + c5420a5 commit 5af9d3a

12 files changed

+124
-58
lines changed

src/model.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
IncludeScope
2424
} from "./scope"
2525
import { JsonapiTypeRegistry } from "./jsonapi-type-registry"
26-
import { camelize } from "inflected"
26+
import { camelize, underscore, dasherize } from "inflected"
2727
import { ILogger, logger as defaultLogger } from "./logger"
2828
import { MiddlewareStack, BeforeFilter, AfterFilter } from "./middleware-stack"
2929
import { Omit } from "./util/omit"
@@ -39,14 +39,21 @@ import { cloneDeep } from "./util/clonedeep"
3939
import { nonenumerable } from "./util/decorators"
4040
import { IncludeScopeHash } from "./util/include-directive"
4141

42+
export type KeyCaseValue = "dash" | "camel" | "snake"
43+
44+
export interface KeyCase {
45+
server: KeyCaseValue
46+
client: KeyCaseValue
47+
}
48+
4249
export interface ModelConfiguration {
4350
baseUrl: string
4451
apiNamespace: string
4552
jsonapiType: string
4653
endpoint: string
4754
jwt: string
4855
jwtStorage: string | false
49-
camelizeKeys: boolean
56+
keyCase: KeyCase
5057
strictAttributes: boolean
5158
logger: ILogger
5259
}
@@ -128,7 +135,7 @@ export class JSORMBase {
128135
static endpoint: string
129136
static isBaseClass: boolean
130137
static jwt?: string
131-
static camelizeKeys: boolean = true
138+
static keyCase: KeyCase = { server: "snake", client: "camel" }
132139
static strictAttributes: boolean = false
133140
static logger: ILogger = defaultLogger
134141
static sync: boolean = false
@@ -344,7 +351,7 @@ export class JSORMBase {
344351
id?: string
345352
temp_id?: string
346353
stale: boolean = false
347-
storeKey: string = ''
354+
storeKey: string = ""
348355

349356
@nonenumerable relationships: Record<string, JSORMBase | JSORMBase[]> = {}
350357
@nonenumerable klass: typeof JSORMBase
@@ -425,44 +432,45 @@ export class JSORMBase {
425432
}
426433

427434
_onStoreChange: Function
428-
private onStoreChange() : Function {
435+
private onStoreChange(): Function {
429436
if (this._onStoreChange) return this._onStoreChange
430-
this._onStoreChange = (_event: any, attrs: any) => {
431-
Object.keys(attrs).forEach((k) => {
437+
this._onStoreChange = (_event: any, attrs: any) => {
438+
Object.keys(attrs).forEach(k => {
432439
let self = this as any
433440
if (self[k] !== attrs[k]) self[k] = attrs[k]
434441
})
435442
}
436443
return this._onStoreChange
437444
}
438445

439-
unlisten() : void {
446+
unlisten(): void {
440447
if (!this.klass.sync) return
441448
if (this.storeKey) {
442449
EventBus.removeEventListener(this.storeKey, this.onStoreChange())
443450
}
444451

445-
Object.keys(this.relationships).forEach((k) => {
452+
Object.keys(this.relationships).forEach(k => {
446453
let related = this.relationships[k]
447454

448455
if (related) {
449456
if (Array.isArray(related)) {
450-
related.forEach((r) => r.unlisten() )
457+
related.forEach(r => r.unlisten())
451458
} else {
452459
related.unlisten()
453460
}
454461
}
455462
})
456463
}
457464

458-
listen() : void {
465+
listen(): void {
459466
if (!this.klass.sync) return
460-
if (!this._onStoreChange) { // not already registered
467+
if (!this._onStoreChange) {
468+
// not already registered
461469
EventBus.addEventListener(this.storeKey, this.onStoreChange())
462470
}
463471
}
464472

465-
reset() : void {
473+
reset(): void {
466474
if (this.klass.sync) {
467475
this.klass.store.updateOrCreate(this)
468476
this.listen()
@@ -527,9 +535,7 @@ export class JSORMBase {
527535
if (attrs.hasOwnProperty(key)) {
528536
let attributeName = key
529537

530-
if (this.klass.camelizeKeys) {
531-
attributeName = camelize(key, false)
532-
}
538+
attributeName = this.klass.deserializeKey(key)
533539

534540
if (key === "id" || this.klass.attributeList[attributeName]) {
535541
;(<any>this)[attributeName] = attrs[key]
@@ -754,6 +760,34 @@ export class JSORMBase {
754760
return this.baseClass
755761
}
756762

763+
static serializeKey(key: string): string {
764+
switch (this.keyCase.server) {
765+
case "dash": {
766+
return dasherize(underscore(key))
767+
}
768+
case "snake": {
769+
return underscore(key)
770+
}
771+
case "camel": {
772+
return camelize(underscore(key), false)
773+
}
774+
}
775+
}
776+
777+
static deserializeKey(key: string): string {
778+
switch (this.keyCase.client) {
779+
case "dash": {
780+
return dasherize(underscore(key))
781+
}
782+
case "snake": {
783+
return underscore(key)
784+
}
785+
case "camel": {
786+
return camelize(underscore(key), false)
787+
}
788+
}
789+
}
790+
757791
async destroy(): Promise<boolean> {
758792
const url = this.klass.url(this.id)
759793
const verb = "delete"
@@ -853,4 +887,4 @@ export const isModelInstance = (arg: any): arg is JSORMBase => {
853887
return false
854888
}
855889
return isModelClass(arg.constructor.currentClass)
856-
}
890+
}

src/util/deserialize.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { JsonapiTypeRegistry } from "../jsonapi-type-registry"
22
import { JSORMBase } from "../model"
3-
import { camelize } from "inflected"
43
import {
54
IncludeDirective,
65
IncludeScopeHash,
@@ -236,11 +235,7 @@ class Deserializer {
236235
) {
237236
for (const key in relationships) {
238237
if (relationships.hasOwnProperty(key)) {
239-
let relationName = key
240-
241-
if (instance.klass.camelizeKeys) {
242-
relationName = camelize(key, false)
243-
}
238+
let relationName = instance.klass.deserializeKey(key)
244239

245240
if (instance.klass.attributeList[relationName]) {
246241
const relationData = relationships[key].data

src/util/validation-errors.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { JSORMBase } from "../model"
2-
import { camelize } from "inflected"
32
import { JsonapiResponseDoc, JsonapiErrorMeta } from "../jsonapi-spec"
43

54
export class ValidationErrors {
@@ -41,11 +40,7 @@ export class ValidationErrors {
4140
errorsAccumulator: Record<string, string>,
4241
meta: JsonapiErrorMeta
4342
) {
44-
let attribute = meta.attribute
45-
46-
if (this.model.klass.camelizeKeys) {
47-
attribute = camelize(attribute, false)
48-
}
43+
let attribute = this.model.klass.deserializeKey(meta.attribute)
4944

5045
errorsAccumulator[attribute] = meta.message
5146
}

src/util/write-payload.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { JSORMBase, ModelRecord } from "../model"
22
import { IncludeDirective, IncludeScopeHash } from "./include-directive"
33
import { IncludeScope } from "../scope"
44
import { tempId } from "./temp-id"
5-
import { underscore } from "inflected"
65
import {
76
JsonapiRequestDoc,
87
JsonapiResourceIdentifier,
@@ -33,10 +32,8 @@ export class WritePayload<T extends JSORMBase> {
3332
const attrs: ModelRecord<T> = {}
3433

3534
this._eachAttribute((key, value) => {
36-
const snakeKey = underscore(key)
37-
3835
if (!this.model.isPersisted || this.model.changes()[key]) {
39-
attrs[snakeKey] = value
36+
attrs[this.model.klass.serializeKey(key)] = value
4037
}
4138
})
4239

@@ -88,8 +85,8 @@ export class WritePayload<T extends JSORMBase> {
8885
const nested = (<any>this.includeDirective)[key]
8986

9087
let idOnly = false
91-
if (key.indexOf('.') > -1) {
92-
key = key.split('.')[0]
88+
if (key.indexOf(".") > -1) {
89+
key = key.split(".")[0]
9390
idOnly = true
9491
}
9592

@@ -101,8 +98,8 @@ export class WritePayload<T extends JSORMBase> {
10198
relatedModels.forEach(relatedModel => {
10299
if (
103100
idOnly ||
104-
this.model.hasDirtyRelation(key, relatedModel) ||
105-
relatedModel.isDirty(nested)
101+
this.model.hasDirtyRelation(key, relatedModel) ||
102+
relatedModel.isDirty(nested)
106103
) {
107104
data.push(this._processRelatedModel(relatedModel, nested, idOnly))
108105
}
@@ -115,15 +112,15 @@ export class WritePayload<T extends JSORMBase> {
115112
// (maybe the "department" is not dirty, but the employee changed departments
116113
if (
117114
idOnly ||
118-
this.model.hasDirtyRelation(key, relatedModels) ||
119-
relatedModels.isDirty(nested)
115+
this.model.hasDirtyRelation(key, relatedModels) ||
116+
relatedModels.isDirty(nested)
120117
) {
121118
data = this._processRelatedModel(relatedModels, nested, idOnly)
122119
}
123120
}
124121

125122
if (data) {
126-
_relationships[underscore(key)] = { data }
123+
_relationships[this.model.klass.serializeKey(key)] = { data }
127124
}
128125
}
129126
})
@@ -169,7 +166,11 @@ export class WritePayload<T extends JSORMBase> {
169166

170167
// private
171168

172-
private _processRelatedModel(model: T, nested: IncludeScopeHash, idOnly: boolean) {
169+
private _processRelatedModel(
170+
model: T,
171+
nested: IncludeScopeHash,
172+
idOnly: boolean
173+
) {
173174
model.clearErrors()
174175

175176
if (!model.isPersisted) {

test/fixtures.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,14 @@ export class PersonWithExtraAttr extends Person {
3030
extraThing: string
3131
}
3232

33-
@Model({ camelizeKeys: false })
33+
@Model({ keyCase: { server: "snake", client: "snake" } })
3434
export class PersonWithoutCamelizedKeys extends Person {
3535
@Attr first_name: string
3636
}
3737

38+
@Model({ keyCase: { server: "dash", client: "camel" } })
39+
export class PersonWithDasherizedKeys extends Person {}
40+
3841
@Model({
3942
endpoint: "/v1/authors",
4043
jsonapiType: "authors"
@@ -67,7 +70,7 @@ export const NonFictionAuthor = Author.extend({
6770
static: {
6871
endpoint: "/v1/non_fiction_authors",
6972
jsonapiType: "non_fiction_authors",
70-
camelizeKeys: false
73+
keyCase: { server: "snake", client: "snake" }
7174
},
7275

7376
attrs: {

test/integration/relations.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe("Relations", () => {
9797
})
9898
})
9999

100-
describe("when camelizeKeys is false", () => {
100+
describe("when keyCase is snake_case", () => {
101101
beforeEach(() => {
102102
fetchMock.get(
103103
"http://example.com/api/v1/non_fiction_authors/1?include=books,multi_words",
@@ -107,7 +107,7 @@ describe("Relations", () => {
107107

108108
afterEach(fetchMock.restore)
109109

110-
it("Doesn't convert relationships to snake_case if camelization is off", async () => {
110+
it("Doesn't convert relationships to snake_case if keyCase.to is snake is off", async () => {
111111
const data = await resultData(
112112
NonFictionAuthor.includes(["books", "multi_words"]).find(1)
113113
)

test/integration/validations.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const resetMocks = () => {
2323
status: "422",
2424
title: "Validation Error",
2525
detail: "Last Name cannot be blank",
26-
meta: { attribute: "last_name", message: "cannot be blank" }
26+
meta: { attribute: "last-name", message: "cannot be blank" }
2727
},
2828
{
2929
code: "unprocessable_entity",
@@ -122,13 +122,13 @@ describe("validations", () => {
122122
})
123123
})
124124

125-
describe("when camelizeKeys is false", () => {
125+
describe("when keyCase.to is snake", () => {
126126
beforeEach(() => {
127-
instance.klass.camelizeKeys = false
127+
instance.klass.keyCase.client = "snake"
128128
})
129129

130130
afterEach(() => {
131-
instance.klass.camelizeKeys = true
131+
instance.klass.keyCase.client = "camel"
132132
})
133133

134134
it("does not camelize the error keys", async () => {

test/unit/decorators.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ describe("Decorators", () => {
3535

3636
it("preserves defaults for unspecified items", () => {
3737
expect(TestModel.baseUrl).to.eq("http://please-set-a-base-url.com")
38-
expect(TestModel.camelizeKeys).to.be.true
38+
expect(TestModel.keyCase.server).to.eq("snake")
39+
expect(TestModel.keyCase.client).to.eq("camel")
3940
})
4041

4142
it("correctly assigns options", () => {

test/unit/model-attributes.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ describe("Model attributes", () => {
2020
expect(person.firstName).to.eq("Joe")
2121
})
2222

23-
it("does not camlize underscored strings if camelization is disabled", () => {
23+
it("camelizes dasherized strings", function() {
24+
const person = new Person({ "first-name": "Joe" })
25+
expect(person.firstName).to.eq("Joe")
26+
})
27+
28+
it("does not camlize underscored strings if keys.to is snake", () => {
2429
const person = new PersonWithoutCamelizedKeys({ first_name: "Joe" })
2530
expect(person.firstName).to.eq(undefined)
2631
expect(person.first_name).to.eq("Joe")

test/unit/model-class-typings.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe("Model Class static attributes typings", () => {
4444
const endpoint: string = RootClass.endpoint
4545
const jwt: string | undefined = RootClass.jwt
4646
const jwtLocalStorage: string | false = RootClass.jwtLocalStorage
47-
const camelizeKeys: boolean = RootClass.camelizeKeys
47+
const keyCase: KeyCase = RootClass.keyCase
4848
const strictAttributes: boolean = RootClass.strictAttributes
4949
})
5050
})

0 commit comments

Comments
 (0)