Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement 'load' on Model and ItemManager #29

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 55 additions & 53 deletions src/ItemManager.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { readFileSync } from 'node:fs'
import * as fs from 'node:fs/promises'

import { getSourceFile } from '@liquid-labs/federated-json'
import { getSourceFile, readFJSON } from '@liquid-labs/federated-json'

import { ListManager } from './ListManager'
import { Item } from './Item'
Expand Down Expand Up @@ -30,58 +29,23 @@ const ItemManager = class {
itemConfig
}) {
// set the source file
this.#fileName = fileName || getSourceFile(items)
this.#fileName = fileName || (items !== undefined && getSourceFile(items))
// read from source file if indicated
if (readFromFile === true && items && items.length > 0) {
throw new Error(`Cannot specify both 'readFromFile : true' and 'items' when loading ${this.itemsName}.`)
}
if (readFromFile === true && !fileName) {
throw new Error(`Must specify 'fileName' when 'readFromFile : true' while loading ${this.itemsName}.`)
}
if (readFromFile === true) {
items = JSON.parse(readFileSync(fileName))
}

// set manuall set itemConfig
this.#itemConfigCache = itemConfig

items = items || []
// normalize and guarantee uniqueness of items (based on ID)
const seen = {}
const hasExplicitId = this.#itemConfig.keyField === 'id'
items.forEach((item) => {
// add standard 'id' field if not present.
if (hasExplicitId === true && !('id' in item)) {
throw new Error("Key field 'id' not found on at least one item.")
}
if (hasExplicitId === false && 'id' in item) {
throw new Error(`Inferred/reserved 'id' found on at least one ${this.itemName} item (key field is: ${this.keyField}).`)
}
item.id = item.id || this.idNormalizer(item[this.keyField])
if (seen[item.id] === true) {
throw new Error(`Found items with duplicate key field '${this.keyField}' values ('${item.id}') in the ${this.itemsName} list.`)
}
seen[item.id] = true
})

if (hasExplicitId === false) {
const origDataCleaner = this.#itemConfig.dataCleaner
const newCleaner = origDataCleaner === undefined
? (data) => { delete data.id; return data }
: (data) => {
delete data.id
return origDataCleaner(data)
}
this.#itemConfigCache = Object.assign({}, this.#itemConfig, { dataCleaner : newCleaner })
Object.freeze(this.#itemConfigCache)
}
this.#itemConfigCache = itemConfig || this.constructor.itemConfig

// setup ListManager
this.listManager = new ListManager({
className : this.itemsName,
keyField : this.keyField,
idNormalizer : this.idNormalizer,
items
idNormalizer : this.idNormalizer
})

// setup indexes
Expand All @@ -90,38 +54,63 @@ const ItemManager = class {
additionalItemCreationOptions
)
this.#addIndexes(indexes)
}

// TODO: switch implementatiosn to set itemConfig directly, then we can do away with the 'Cache' convention and this constructor test.
get #itemConfig() {
// return Object.assign({}, this.#itemConfigCache || this.constructor.itemConfig)
return this.#itemConfigCache || this.constructor.itemConfig
if (this.keyField !== 'id') {
const origDataCleaner = this.#itemConfigCache.dataCleaner
const newCleaner = origDataCleaner === undefined
? (data) => { delete data.id; return data }
: (data) => {
delete data.id
return origDataCleaner(data)
}
this.#itemConfigCache = Object.assign({}, this.#itemConfigCache, { dataCleaner : newCleaner })
Object.freeze(this.#itemConfigCache)
}

if (readFromFile === true) {
this.load()
}
else {
this.load({ items })
}
}

// item config convenience accessors
get dataCleaner() { return this.#itemConfig.dataCleaner }
get dataCleaner() { return this.#itemConfigCache.dataCleaner }

get dataFlattener() { return this.#itemConfig.dataFlattener }
get dataFlattener() { return this.#itemConfigCache.dataFlattener }

/**
* See [Item.idNormalizer](./Item.md#idnormalizer)
*/
get idNormalizer() { return this.#itemConfig.idNormalizer || passthruNormalizer }
get idNormalizer() { return this.#itemConfigCache.idNormalizer || passthruNormalizer }

get itemClass() { return this.#itemConfig.itemClass }
get itemClass() { return this.#itemConfigCache.itemClass }

get itemName() { return this.#itemConfig.itemName }
get itemName() { return this.#itemConfigCache.itemName }

/**
* See [Item.keyField](./Item.md#keyfield)
*/
get keyField() { return this.#itemConfig.keyField }
get keyField() { return this.#itemConfigCache.keyField }

get itemsName() { return this.#itemConfig.itemsName }
get itemsName() { return this.#itemConfigCache.itemsName }

add(data) {
data = ensureRaw(data)
if (data.id === undefined) data.id = this.idNormalizer(data[this.keyField])
const keyField = this.keyField
const hasExplicitId = keyField === 'id'

// add standard 'id' field if not present.
if (data[keyField] === undefined) {
throw new Error(`Key field '${keyField}' not found on at least one item while loading ${this.itemsName}.`)
}
if (hasExplicitId === false && 'id' in data) {
throw new Error(`Inferred/reserved 'id' found on at least one ${this.itemName} item (key field is: ${this.keyField}) while loading ${this.itemsName}.`)
}

// normalize ID
if (data.id === undefined) data.id = this.idNormalizer(data[keyField])

if (this.has(data.id)) {
throw new Error(`Cannot add ${this.itemName} with existing key '${data.id}'; try 'update'.`)
Expand Down Expand Up @@ -149,6 +138,19 @@ const ItemManager = class {

has(name) { return !!this.#indexById[name] }

load({ items } = {}) {
if (!this.#fileName && items === undefined) {
throw new Error(`No 'file name' defined for ${this.itemsName} ItemManager; cannot 'load'.`)
}

this.truncate()
// TODO: really just want JSON and YAML agnostic processing; federated is overkill
items = items || readFJSON(this.#fileName)
for (const item of items) {
this.add(item)
}
}

update(data, { skipGet = false, ...rest } = {}) {
data = ensureRaw(data)
const id = data[this.keyField]
Expand Down
2 changes: 1 addition & 1 deletion src/ListManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const ListManager = class {
* the incoming list with something like `items: [...items]` unless you can guarantee that the array will not be
* modified.
*/
constructor({ items, keyField = 'id', idIndexName = 'byId', className }) {
constructor({ items = [], keyField = 'id', idIndexName = 'byId', className }) {
this.#items = items
this.#keyField = keyField
this.#idIndex = this.addIndex({
Expand Down
9 changes: 9 additions & 0 deletions src/Model.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ const Model = class {
this.#validators.push(validator)
}

load() {
for (const itemManager of this.#rootItemManagers) {
itemManager.load()
}
for (const subModel of this.#subModels) {
subModel.load()
}
}

async save({ noValidate = false }) {
if (noValidate !== true) {
const { errors } = this.validate()
Expand Down
24 changes: 24 additions & 0 deletions src/test/ItemManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ describe('ItemManager', () => {
expect(() => foos.get('a foo', { required : true })).toThrow(/Did not find required foo 'a foo'./))
})

describe('load()', () => {
test('load items from the original file', () => {
const itemManager =
new ItemManager({ fileName : dataPath, items : [], readFromFile : false, itemConfig : fooConfig })
expect(itemManager.list({ rawData : true })).toHaveLength(0)
itemManager.load()
expect(itemManager.list({ rawData : true })).toHaveLength(1)
})

test('will re-load items from the original file', () => {
const itemManager =
new ItemManager({ fileName : dataPath, readFromFile : true, itemConfig : fooConfig })
const item = itemManager.get('Bobby', { rawData : true })
item.foo = 'bar'
itemManager.update(item)
const updatedItem = itemManager.get('Bobby', { rawData : true })
expect(updatedItem.foo).toBe('bar')

itemManager.load()
const reloadedItem = itemManager.get('Bobby')
expect(reloadedItem.foo).toBe(undefined)
})
})

describe('save()', () => {
const baseTmpDir = os.tmpdir()
const tmpDir = fsPath.join(baseTmpDir, 'liquid-labs', 'resource-model')
Expand Down