@naturalcycles/db-lib
Version:
Lowest Common Denominator API to supported Databases
361 lines (360 loc) • 15.1 kB
JavaScript
import { _sortBy } from '@naturalcycles/js-lib/array/sort.js';
import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js';
import { _deepCopy, _filterObject, _omit, _pick } from '@naturalcycles/js-lib/object';
import { getJoiValidationFunction } from '@naturalcycles/nodejs-lib/joi';
import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
import { CommonDao } from '../commondao/common.dao.js';
import { DBQuery } from '../query/dbQuery.js';
import { createTestItemBM, createTestItemsBM, TEST_TABLE, testItemBMJsonSchema, testItemBMSchema, } from './test.model.js';
export async function runCommonDaoTest(db, quirks = {}) {
// this is because vitest cannot be "required" from cjs
const { test, expect } = await import('vitest');
const { support } = db;
const dao = new CommonDao({
table: TEST_TABLE,
db,
validateBM: getJoiValidationFunction(testItemBMSchema),
});
const items = createTestItemsBM(3);
const itemsClone = _deepCopy(items);
// deepFreeze(items) // mutation of id/created/updated is allowed now! (even expected)
const item1 = items[0];
const expectedItems = items.map(i => ({
...i,
updated: expect.any(Number),
}));
test('ping', async () => {
await dao.ping();
});
// CREATE TABLE, DROP
if (support.createTable) {
test('createTable, dropIfExists=true', async () => {
await dao.createTable(testItemBMJsonSchema, { dropIfExists: true });
});
}
if (support.queries) {
// DELETE ALL initially
test('deleteByIds test items', async () => {
const rows = await dao.query().select(['id']).runQuery();
await db.deleteByQuery(DBQuery.create(TEST_TABLE).filter('id', 'in', rows.map(r => r.id)));
});
// QUERY empty
test('runQuery(all), runQueryCount should return empty', async () => {
expect(await dao.query().runQuery()).toEqual([]);
expect(await dao.query().runQueryCount()).toBe(0);
});
}
// GET empty
test('getByIds(item1.id) should return empty', async () => {
const [item1Loaded] = await dao.getByIds([item1.id]);
expect(item1Loaded).toBeUndefined();
expect(await dao.getById(item1.id)).toBeNull();
});
test('getByIds([]) should return []', async () => {
expect(await dao.getByIds([])).toEqual([]);
});
test('getByIds(...) should return empty', async () => {
expect(await dao.getByIds(['abc', 'abcd'])).toEqual([]);
});
// TimeMachine
if (support.timeMachine) {
test('getByIds(...) 10 minutes ago should return []', async () => {
expect(await dao.getByIds([item1.id, 'abc'], {
readAt: localTime.now().minus(10, 'minute').unix,
})).toEqual([]);
});
}
// SAVE
if (support.nullValues) {
test('should allow to save and load null values', async () => {
const item3 = {
...createTestItemBM(3),
k2: null,
};
// deepFreeze(item3) // no, Dao is expected to mutate object to add id, created, updated
await dao.save(item3);
const item3Loaded = await dao.requireById(item3.id);
expectMatch([item3], [item3Loaded], quirks);
expect(item3Loaded.k2).toBeNull();
expect(Object.keys(item3)).toContain('k2');
expect(item3.k2).toBeNull();
});
}
test('undefined values should not be saved/loaded', async () => {
const item3 = {
...createTestItemBM(3),
k2: undefined,
};
// deepFreeze(item3) // no, Dao is expected to mutate object to add id, created, updated
const expected = { ...item3 };
delete expected.k2;
await dao.save(item3);
expected.updated = item3.updated; // as it's mutated
const item3Loaded = await dao.requireById(item3.id);
expectMatch([expected], [item3Loaded], quirks);
expect(item3Loaded.k2).toBeUndefined();
expect(Object.keys(item3Loaded)).not.toContain('k2');
expect(Object.keys(item3)).toContain('k2');
expect(item3.k2).toBeUndefined();
});
test('saveBatch test items', async () => {
const itemsSaved = await dao.saveBatch(items);
expect(itemsSaved[0]).toBe(items[0]); // expect "same object" returned
// no unnecessary mutation
const { updated: _, ...clone } = itemsClone[0];
expect(items[0]).toMatchObject(clone);
expectMatch(expectedItems, itemsSaved, quirks);
});
if (support.increment) {
test('increment', async () => {
await dao.incrementBatch('k3', { id1: 1, id2: 2 });
let rows = await dao.query().runQuery();
rows = _sortBy(rows, r => r.id);
const expected = expectedItems.map(r => {
if (r.id === 'id1') {
return {
...r,
k3: r.k3 + 1,
};
}
if (r.id === 'id2') {
return {
...r,
k3: r.k3 + 2,
};
}
return r;
});
expectMatch(expected, rows, quirks);
// reset the changes
await dao.increment('k3', 'id1', -1);
await dao.increment('k3', 'id2', -2);
});
}
// GET not empty
test('getByIds all items', async () => {
const rows = await dao.getByIds(items.map(i => i.id).concat('abcd'));
expectMatch(expectedItems, _sortBy(rows, r => r.id), quirks);
});
// QUERY
if (support.queries) {
test('runQuery(all) should return all items', async () => {
let rows = await dao.query().runQuery();
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems, rows, quirks);
});
if (support.dbQueryFilter) {
test('query even=true', async () => {
let rows = await dao.query().filter('even', '==', true).runQuery();
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems.filter(i => i.even), rows, quirks);
});
test('query nested property', async () => {
let rows = await dao
.query()
.filter('nested.foo', '==', 1)
.runQuery();
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems.filter(i => i.nested?.foo === 1), rows, quirks);
});
}
if (support.dbQueryOrder) {
test('query order by k1 desc', async () => {
const rows = await dao.query().order('k1', true).runQuery();
expectMatch([...expectedItems].reverse(), rows, quirks);
});
}
if (support.dbQuerySelectFields) {
test('projection query with only ids', async () => {
let rows = await dao.query().select(['id']).runQuery();
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems.map(item => _pick(item, ['id'])), rows, quirks);
});
}
test('runQueryCount should return 3', async () => {
expect(await dao.query().runQueryCount()).toBe(3);
});
}
// STREAM
if (support.streaming) {
test('streamQueryForEach all', async () => {
let rows = [];
await dao
.query()
.streamQuery()
.forEachSync(bm => void rows.push(bm));
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems, rows, quirks);
});
test('streamQuery all', async () => {
let rows = await dao.query().streamQuery().toArray();
rows = _sortBy(rows, r => r.id);
expectMatch(expectedItems, rows, quirks);
});
test('streamQueryIdsForEach all', async () => {
let ids = [];
await dao
.query()
.streamQueryIds()
.forEachSync(id => void ids.push(id));
ids = ids.sort();
expectMatch(expectedItems.map(i => i.id), ids, quirks);
});
test('streamQueryIds all', async () => {
let ids = await dao.query().streamQueryIds().toArray();
ids = ids.sort();
expectMatch(expectedItems.map(i => i.id), ids, quirks);
});
test('streamSave', async () => {
const items2 = createTestItemsBM(2).map(i => ({ ...i, id: i.id + '_str' }));
const ids = items2.map(i => i.id);
await dao.streamSave(Pipeline.fromArray(items2));
const items2Loaded = await dao.getByIds(ids);
expectMatch(items2, items2Loaded, quirks);
// cleanup
await dao.query().filterIn('id', ids).deleteByQuery();
});
}
// DELETE BY
if (support.queries) {
test('deleteByQuery even=false', async () => {
const deleted = await dao.query().filter('even', '==', false).deleteByQuery();
expect(deleted).toBe(items.filter(item => !item.even).length);
expect(await dao.query().runQueryCount()).toBe(1);
});
test('cleanup', async () => {
// CLEAN UP
await dao.query().deleteByQuery();
});
}
if (support.transactions) {
/**
* Returns expected items in the DB after the preparation.
*/
async function prepare() {
// cleanup
await dao.query().deleteByQuery();
const itemsToSave = [items[0], { ...items[2], k1: 'k1_mod' }];
await dao.saveBatch(itemsToSave);
return itemsToSave;
}
test('transaction happy path', async () => {
// cleanup
await dao.query().deleteByQuery();
// Test that id, created, updated are created
const now = localTime.nowUnix();
await dao.runInTransaction(async (tx) => {
const row = _omit(item1, ['id', 'created', 'updated']);
await tx.save(dao, row);
});
const loaded = await dao.query().runQuery();
expect(loaded.length).toBe(1);
expect(loaded[0].id).toBeDefined();
expect(loaded[0].created).toBeGreaterThanOrEqual(now);
expect(loaded[0].updated).toBe(loaded[0].created);
await dao.runInTransaction(async (tx) => {
await tx.deleteById(dao, loaded[0].id);
});
// saveBatch [item1, 2, 3]
// save item3 with k1: k1_mod
// delete item2
// remaining: item1, item3_with_k1_mod
await dao.runInTransaction(async (tx) => {
await tx.saveBatch(dao, items);
await tx.save(dao, { ...items[2], k1: 'k1_mod' });
await tx.deleteById(dao, items[1].id);
});
const rows = await dao.query().runQuery();
const expected = [items[0], { ...items[2], k1: 'k1_mod' }];
expectMatch(expected, rows, quirks);
});
if (support.createTransaction) {
test('createTransaction happy path', async () => {
// cleanup
await dao.query().deleteByQuery();
// Test that id, created, updated are created
const now = localTime.nowUnix();
const row = _omit(item1, ['id', 'created', 'updated']);
let tx = await dao.createTransaction();
await tx.save(dao, row);
await tx.commit();
const loaded = await dao.query().runQuery();
expect(loaded.length).toBe(1);
expect(loaded[0].id).toBeDefined();
expect(loaded[0].created).toBeGreaterThanOrEqual(now);
expect(loaded[0].updated).toBe(loaded[0].created);
tx = await dao.createTransaction();
await tx.deleteById(dao, loaded[0].id);
await tx.commit();
// saveBatch [item1, 2, 3]
// save item3 with k1: k1_mod
// delete item2
// remaining: item1, item3_with_k1_mod
tx = await dao.createTransaction();
await tx.saveBatch(dao, items);
await tx.save(dao, { ...items[2], k1: 'k1_mod' });
await tx.deleteById(dao, items[1].id);
await tx.commit();
const rows = await dao.query().runQuery();
const expected = [items[0], { ...items[2], k1: 'k1_mod' }];
expectMatch(expected, rows, quirks);
});
}
test('transaction rollback', async () => {
const expected = await prepare();
let err;
try {
await dao.runInTransaction(async (tx) => {
await tx.deleteById(dao, items[2].id);
await tx.save(dao, { ...items[0], k1: 5 }); // it should fail here
});
}
catch (err2) {
err = err2;
}
expect(err).toBeDefined();
expect(err).toBeInstanceOf(Error);
const rows = await dao.query().runQuery();
expectMatch(expected, rows, quirks);
});
if (support.createTransaction) {
test('createTransaction rollback', async () => {
const expected = await prepare();
let err;
try {
const tx = await dao.createTransaction();
await tx.deleteById(dao, items[2].id);
await tx.save(dao, { ...items[0], k1: 5 }); // it should fail here
await tx.commit();
}
catch (err2) {
err = err2;
}
expect(err).toBeDefined();
expect(err).toBeInstanceOf(Error);
const rows = await dao.query().runQuery();
expectMatch(expected, rows, quirks);
});
}
if (support.queries) {
test('transaction cleanup', async () => {
await dao.query().deleteByQuery();
});
}
}
function expectMatch(expected, actual, quirks) {
// const expectedSorted = sortObjectDeep(expected)
// const actualSorted = sortObjectDeep(actual)
if (quirks.allowBooleansAsUndefined) {
expected = expected.map(r => {
return typeof r !== 'object' ? r : _filterObject(r, (_k, v) => v !== false);
});
}
if (quirks.allowExtraPropertiesInResponse) {
expect(actual).toMatchObject(expected);
}
else {
expect(actual).toEqual(expected);
}
}
}