Include data source tests for query and initialization.master
@@ -7,7 +7,7 @@ type TotalCount = number; | |||
type DeleteResult = unknown; | |||
export interface DataSource<Schema = object, Query = object, ID = string> { | |||
export interface DataSource<Schema extends object = object, Query extends object = object, ID extends string = string> { | |||
initialize(): Promise<unknown>; | |||
getTotalCount?(query?: Query): Promise<TotalCount>; | |||
getMultiple(query?: Query): Promise<Schema[]>; | |||
@@ -1,252 +0,0 @@ | |||
import {readFile, writeFile} from 'fs/promises'; | |||
import {join} from 'path'; | |||
import { Resource, validation as v } from '@modal-sh/yasumi'; | |||
import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend'; | |||
export class JsonLinesDataSource<Data extends Record<string, string>> implements DataSource<Data> { | |||
private path?: string; | |||
private resource?: Resource; | |||
data: Data[] = []; | |||
constructor(private readonly baseDir = '') { | |||
// noop | |||
} | |||
prepareResource< | |||
Schema extends v.BaseSchema = v.BaseSchema, | |||
CurrentName extends string = string, | |||
CurrentRouteName extends string = string | |||
>(resource: Resource<Schema, CurrentName, CurrentRouteName>) { | |||
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); | |||
resource.dataSource = resource.dataSource ?? this; | |||
const originalResourceId = resource.id; | |||
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||
originalResourceId(newIdAttr, params); | |||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||
}; | |||
this.resource = resource; | |||
} | |||
async initialize() { | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
try { | |||
const fileContents = await readFile(this.path, 'utf-8'); | |||
const lines = fileContents.split('\n'); | |||
this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l)); | |||
} catch (err) { | |||
await writeFile(this.path, ''); | |||
} | |||
} | |||
async getTotalCount() { | |||
return this.data.length; | |||
} | |||
async getMultiple() { | |||
return [...this.data]; | |||
} | |||
async newId() { | |||
const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig<any>; | |||
if (typeof idConfig === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theNewId = await idConfig.generationStrategy(this); | |||
return theNewId as string; | |||
} | |||
async getById(idSerialized: string) { | |||
if (typeof this.resource === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||
if (typeof theIdAttr === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const idAttr = theIdAttr as string; | |||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||
if (typeof theIdConfigRaw === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||
const id = theIdConfig.deserialize(idSerialized); | |||
const foundData = this.data.find((s) => s[idAttr] === id); | |||
if (foundData) { | |||
return { | |||
...foundData | |||
}; | |||
} | |||
return null; | |||
} | |||
async create(data: Data) { | |||
if (typeof this.resource === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||
if (typeof theIdAttr === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const idAttr = theIdAttr as string; | |||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||
if (typeof theIdConfigRaw === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||
const newData = { | |||
...data | |||
} as Record<string, unknown>; | |||
if (idAttr in newData) { | |||
newData[idAttr] = theIdConfig.deserialize(newData[idAttr] as string); | |||
} | |||
const newCollection = [ | |||
...this.data, | |||
newData | |||
]; | |||
await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); | |||
return data as Data; | |||
} | |||
async delete(idSerialized: string) { | |||
if (typeof this.resource === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||
if (typeof theIdAttr === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const idAttr = theIdAttr as string; | |||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||
if (typeof theIdConfigRaw === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||
const oldDataLength = this.data.length; | |||
const id = theIdConfig.deserialize(idSerialized); | |||
const newData = this.data.filter((s) => !(s[idAttr] === id)); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return oldDataLength !== newData.length; | |||
} | |||
async emplace(idSerialized: string, dataWithId: Data) { | |||
if (typeof this.resource === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||
if (typeof theIdAttr === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const idAttr = theIdAttr as string; | |||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||
if (typeof theIdConfigRaw === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||
const existing = await this.getById(idSerialized); | |||
const id = theIdConfig.deserialize(idSerialized); | |||
const { [idAttr]: idFromResource, ...data } = dataWithId; | |||
const dataToEmplace = { | |||
...data, | |||
[idAttr]: id, | |||
} as Data; | |||
if (existing) { | |||
const newData = this.data.map((d) => { | |||
if (d[idAttr] === id) { | |||
return dataToEmplace; | |||
} | |||
return d; | |||
}); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return [dataToEmplace, false] as [Data, boolean]; | |||
} | |||
const newData = await this.create(dataToEmplace); | |||
return [newData, true] as [Data, boolean]; | |||
} | |||
async patch(idSerialized: string, data: Partial<Data>) { | |||
if (typeof this.resource === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
if (typeof this.path !== 'string') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdAttr = this.resource.state.shared.get('idAttr'); | |||
if (typeof theIdAttr === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const idAttr = theIdAttr as string; | |||
const theIdConfigRaw = this.resource.state.shared.get('idConfig'); | |||
if (typeof theIdConfigRaw === 'undefined') { | |||
throw new Error('Resource not prepared.'); | |||
} | |||
const theIdConfig = theIdConfigRaw as ResourceIdConfig<any>; | |||
const existing = await this.getById(idSerialized); | |||
if (!existing) { | |||
return null; | |||
} | |||
const newItem = { | |||
...existing, | |||
...data, | |||
} | |||
const id = theIdConfig.deserialize(idSerialized); | |||
const newData = this.data.map((d) => { | |||
if (d[idAttr] === id) { | |||
return newItem; | |||
} | |||
return d; | |||
}); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return newItem as Data; | |||
} | |||
} |
@@ -1,8 +0,0 @@ | |||
import { describe, it, expect } from 'vitest'; | |||
import add from '../src'; | |||
describe('blah', () => { | |||
it('works', () => { | |||
expect(add(1, 1)).toEqual(2); | |||
}); | |||
}); |
@@ -0,0 +1,205 @@ | |||
import {readFile, writeFile} from 'fs/promises'; | |||
import {join} from 'path'; | |||
import { Resource, validation as v } from '@modal-sh/yasumi'; | |||
import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend'; | |||
import assert from 'assert'; | |||
class ResourceNotPreparedError extends Error {} | |||
const resourceNotPreparedError = new ResourceNotPreparedError(); | |||
class ResourceIdNotDesignatedError extends Error {} | |||
export class JsonLinesDataSource< | |||
Schema extends v.BaseSchema = v.BaseSchema, | |||
CurrentName extends string = string, | |||
CurrentRouteName extends string = string, | |||
Data extends object = v.Output<Schema>, | |||
> implements DataSource<Data> { | |||
private path?: string; | |||
private resource?: Resource<Schema, CurrentName, CurrentRouteName>; | |||
data: Data[] = []; | |||
constructor(private readonly baseDir = '') { | |||
// noop | |||
} | |||
prepareResource(resource: Resource<Schema, CurrentName, CurrentRouteName>) { | |||
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); | |||
resource.dataSource = resource.dataSource ?? this; | |||
const originalResourceId = resource.id; | |||
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||
originalResourceId(newIdAttr, params); | |||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||
}; | |||
this.resource = resource; | |||
} | |||
async initialize() { | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
try { | |||
const fileContents = await readFile(this.path, 'utf-8'); | |||
const lines = fileContents.split('\n'); | |||
this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l)); | |||
} catch (err) { | |||
await writeFile(this.path, ''); | |||
} | |||
} | |||
async getTotalCount() { | |||
return this.data.length; | |||
} | |||
async getMultiple() { | |||
return [...this.data]; | |||
} | |||
async newId() { | |||
const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig<any>; | |||
assert(typeof idConfig !== 'undefined', resourceNotPreparedError); | |||
const theNewId = await idConfig.generationStrategy(this); | |||
return theNewId as string; | |||
} | |||
async getById(idSerialized: string) { | |||
assert(typeof this.resource !== 'undefined', resourceNotPreparedError); | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
const idAttr = this.resource.state.shared.get('idAttr'); | |||
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); | |||
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
const id = idConfig.deserialize(idSerialized); | |||
const foundData = this.data.find((s) => (s as any)[idAttr] === id); | |||
if (foundData) { | |||
return { | |||
...foundData | |||
}; | |||
} | |||
return null; | |||
} | |||
async create(data: Data) { | |||
assert(typeof this.resource !== 'undefined', resourceNotPreparedError); | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
const idAttr = this.resource.state.shared.get('idAttr'); | |||
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); | |||
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
const newData = { | |||
...data | |||
} as Record<string, unknown>; | |||
if (idAttr in newData) { | |||
newData[idAttr] = idConfig.deserialize(newData[idAttr] as string); | |||
} | |||
const newCollection = [ | |||
...this.data, | |||
newData | |||
]; | |||
await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); | |||
return data as Data; | |||
} | |||
async delete(idSerialized: string) { | |||
assert(typeof this.resource !== 'undefined', resourceNotPreparedError); | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
const idAttr = this.resource.state.shared.get('idAttr'); | |||
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); | |||
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
const oldDataLength = this.data.length; | |||
const id = idConfig.deserialize(idSerialized); | |||
const newData = this.data.filter((s) => !((s as any)[idAttr] === id)); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return oldDataLength !== newData.length; | |||
} | |||
async emplace(idSerialized: string, dataWithId: Data) { | |||
assert(typeof this.resource !== 'undefined', resourceNotPreparedError); | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
const idAttr = this.resource.state.shared.get('idAttr'); | |||
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); | |||
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
const existing = await this.getById(idSerialized); | |||
const id = idConfig.deserialize(idSerialized); | |||
const { [idAttr]: idFromResource, ...data } = (dataWithId as any); | |||
const dataToEmplace = { | |||
...data, | |||
[idAttr]: id, | |||
} as Data; | |||
if (existing) { | |||
const newData = this.data.map((d) => { | |||
if ((d as any)[idAttr] === id) { | |||
return dataToEmplace; | |||
} | |||
return d; | |||
}); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return [dataToEmplace, false] as [Data, boolean]; | |||
} | |||
const newData = await this.create(dataToEmplace); | |||
return [newData, true] as [Data, boolean]; | |||
} | |||
async patch(idSerialized: string, data: Partial<Data>) { | |||
assert(typeof this.resource !== 'undefined', resourceNotPreparedError); | |||
assert(typeof this.path === 'string', resourceNotPreparedError); | |||
const idAttr = this.resource.state.shared.get('idAttr'); | |||
assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); | |||
const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig<any> | undefined; | |||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||
const existing = await this.getById(idSerialized); | |||
if (!existing) { | |||
return null; | |||
} | |||
const newItem = { | |||
...existing, | |||
...data, | |||
} | |||
const id = idConfig.deserialize(idSerialized); | |||
const newData = this.data.map((d) => { | |||
if ((d as any)[idAttr] === id) { | |||
return newItem; | |||
} | |||
return d; | |||
}); | |||
await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); | |||
return newItem as Data; | |||
} | |||
} |
@@ -0,0 +1,128 @@ | |||
import {describe, it, expect, vi, Mock, beforeAll, beforeEach} from 'vitest'; | |||
import { readFile, writeFile } from 'fs/promises'; | |||
import { JsonLinesDataSource } from '../src'; | |||
import { resource, validation as v } from '@modal-sh/yasumi'; | |||
import {DataSource} from '@modal-sh/yasumi/dist/types/backend'; | |||
vi.mock('fs/promises'); | |||
describe('prepareResource', () => { | |||
beforeAll(() => { | |||
const mockWriteFile = writeFile as Mock; | |||
mockWriteFile.mockImplementation(() => { /* noop */ }) | |||
}); | |||
it('works', () => { | |||
const schema = v.object({}); | |||
const r = resource(schema); | |||
const ds = new JsonLinesDataSource<typeof schema>(); | |||
expect(() => ds.prepareResource(r)).not.toThrow(); | |||
}); | |||
}); | |||
describe('methods', () => { | |||
const dummyItems = [ | |||
{ | |||
id: 1, | |||
name: 'foo', | |||
}, | |||
{ | |||
id: 2, | |||
name: 'bar', | |||
}, | |||
{ | |||
id: 3, | |||
name: 'baz', | |||
}, | |||
]; | |||
const schema = v.object({ | |||
name: v.string(), | |||
}); | |||
let ds: DataSource<v.Output<typeof schema>>; | |||
let mockGenerationStrategy; | |||
beforeEach(() => { | |||
mockGenerationStrategy = vi.fn(); | |||
const r = resource(schema) | |||
.id('id', { | |||
generationStrategy: mockGenerationStrategy, | |||
schema: v.any(), | |||
serialize: (id) => id.toString(), | |||
deserialize: (id) => Number(id.toString()), | |||
}); | |||
ds = new JsonLinesDataSource<typeof schema>(); | |||
ds.prepareResource(r); | |||
}); | |||
beforeEach(() => { | |||
const mockReadFile = readFile as Mock; | |||
mockReadFile.mockReturnValueOnce( | |||
dummyItems.map((i) => JSON.stringify(i)).join('\n') | |||
); | |||
}); | |||
describe('initialize', () => { | |||
it('works', async () => { | |||
try { | |||
await ds.initialize(); | |||
} catch { | |||
expect.fail('Could not initialize data source.'); | |||
} | |||
}); | |||
}); | |||
describe('operations', () => { | |||
beforeEach(async () => { | |||
await ds.initialize(); | |||
}); | |||
describe('getTotalCount', () => { | |||
it('works', async () => { | |||
if (typeof ds.getTotalCount !== 'function') { | |||
return; | |||
} | |||
const totalCount = await ds.getTotalCount(); | |||
expect(totalCount).toBe(dummyItems.length); | |||
}); | |||
}); | |||
describe('getMultiple', () => { | |||
it('works', async () => { | |||
const items = await ds.getMultiple(); | |||
expect(items).toEqual(dummyItems); | |||
}); | |||
}); | |||
describe('getById', () => { | |||
it('works', async () => { | |||
const item = await ds.getById('2'); | |||
const expected = dummyItems.find((i) => i.id === 2); | |||
expect(item).toEqual(expected); | |||
}); | |||
}); | |||
describe('getSingle', () => { | |||
it('works', async () => { | |||
if (typeof ds.getSingle !== 'function') { | |||
return; | |||
} | |||
const item = await ds.getSingle(); | |||
const expected = dummyItems[0]; | |||
expect(item).toEqual(expected); | |||
}); | |||
}); | |||
describe.todo('create'); | |||
describe.todo('delete'); | |||
describe.todo('emplace'); | |||
describe.todo('patch'); | |||
describe('newId', () => { | |||
it('works', async () => { | |||
const v = 'newId'; | |||
mockGenerationStrategy.mockResolvedValueOnce(v); | |||
const id = await ds.newId(); | |||
expect(id).toBe(v); | |||
}); | |||
}); | |||
}); | |||
}); |
@@ -37,11 +37,11 @@ importers: | |||
specifier: ^1.4.0 | |||
version: 1.5.0(@types/node@20.12.7) | |||
packages/data-source-file-jsonl: | |||
packages/data-sources/file-jsonl: | |||
dependencies: | |||
'@modal-sh/yasumi': | |||
specifier: '*' | |||
version: link:../core | |||
version: link:../../core | |||
devDependencies: | |||
'@types/node': | |||
specifier: ^20.11.0 | |||
@@ -1,2 +1,2 @@ | |||
packages: | |||
- 'packages/*' | |||
- 'packages/**' |