Browse Source

Add data source tests

Include data source tests for query and initialization.
master
TheoryOfNekomata 8 months ago
parent
commit
0a8202c4e8
12 changed files with 337 additions and 264 deletions
  1. +1
    -1
      packages/core/src/backend/data-source.ts
  2. +0
    -252
      packages/data-source-file-jsonl/src/index.ts
  3. +0
    -8
      packages/data-source-file-jsonl/test/index.test.ts
  4. +0
    -0
      packages/data-sources/file-jsonl/.gitignore
  5. +0
    -0
      packages/data-sources/file-jsonl/LICENSE
  6. +0
    -0
      packages/data-sources/file-jsonl/package.json
  7. +0
    -0
      packages/data-sources/file-jsonl/pridepack.json
  8. +205
    -0
      packages/data-sources/file-jsonl/src/index.ts
  9. +128
    -0
      packages/data-sources/file-jsonl/test/index.test.ts
  10. +0
    -0
      packages/data-sources/file-jsonl/tsconfig.json
  11. +2
    -2
      pnpm-lock.yaml
  12. +1
    -1
      pnpm-workspace.yaml

+ 1
- 1
packages/core/src/backend/data-source.ts View File

@@ -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[]>;


+ 0
- 252
packages/data-source-file-jsonl/src/index.ts View File

@@ -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;
}
}

+ 0
- 8
packages/data-source-file-jsonl/test/index.test.ts View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest';
import add from '../src';

describe('blah', () => {
it('works', () => {
expect(add(1, 1)).toEqual(2);
});
});

packages/data-source-file-jsonl/.gitignore → packages/data-sources/file-jsonl/.gitignore View File


packages/data-source-file-jsonl/LICENSE → packages/data-sources/file-jsonl/LICENSE View File


packages/data-source-file-jsonl/package.json → packages/data-sources/file-jsonl/package.json View File


packages/data-source-file-jsonl/pridepack.json → packages/data-sources/file-jsonl/pridepack.json View File


+ 205
- 0
packages/data-sources/file-jsonl/src/index.ts View File

@@ -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;
}
}

+ 128
- 0
packages/data-sources/file-jsonl/test/index.test.ts View File

@@ -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);
});
});
});
});

packages/data-source-file-jsonl/tsconfig.json → packages/data-sources/file-jsonl/tsconfig.json View File


+ 2
- 2
pnpm-lock.yaml View File

@@ -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
- 1
pnpm-workspace.yaml View File

@@ -1,2 +1,2 @@
packages:
- 'packages/*'
- 'packages/**'

Loading…
Cancel
Save