2
0
mirror of https://github.com/frappe/books.git synced 2024-11-09 23:30:56 +00:00

test: add more dbcore tests

- fix bugs in core.ts
This commit is contained in:
18alantom 2022-03-30 12:46:01 +05:30
parent f5d795d95b
commit 18dd3f8518
5 changed files with 451 additions and 79 deletions

View File

@ -6,14 +6,14 @@ import {
DuplicateEntryError,
LinkValidationError,
NotFoundError,
ValueError
ValueError,
} from '../../frappe/utils/errors';
import {
Field,
FieldTypeEnum,
RawValue,
SchemaMap,
TargetField
TargetField,
} from '../../schemas/types';
import { getDefaultMetaFieldValueMap, sqliteTypeMap, SYSTEM } from '../common';
import {
@ -21,11 +21,15 @@ import {
FieldValueMap,
GetAllOptions,
GetQueryBuilderOptions,
QueryFilter
QueryFilter,
} from './types';
/**
* Db Core Call Sequence
* # DatabaseCore
* This is the ORM, the DatabaseCore interface (function signatures) should be
* replicated by the frontend demuxes and all the backend muxes.
*
* ## Db Core Call Sequence
*
* 1. Init core: `const db = new DatabaseCore(dbPath)`.
* 2. Connect db: `db.connect()`. This will allow for raw queries to be executed.
@ -33,6 +37,10 @@ import {
* 4. Migrate: `await db.migrate()`. This will create absent tables and update the tables' shape.
* 5. ORM function execution: `db.get(...)`, `db.insert(...)`, etc.
* 6. Close connection: `await db.close()`.
*
* Note: Meta values: created, modified, createdBy, modifiedBy are set by DatabaseCore
* only for schemas that are SingleValue. Else they have to be passed by the caller in
* the `fieldValueMap`.
*/
export default class DatabaseCore {
@ -141,10 +149,10 @@ export default class DatabaseCore {
async get(
schemaName: string,
name: string = '',
fields: string | string[] = '*'
fields?: string | string[]
): Promise<FieldValueMap> {
const isSingle = this.schemaMap[schemaName].isSingle;
if (!isSingle && !name) {
const schema = this.schemaMap[schemaName];
if (!schema.isSingle && !name) {
throw new ValueError('name is mandatory');
}
@ -154,38 +162,37 @@ export default class DatabaseCore {
* is ignored.
*/
let fieldValueMap: FieldValueMap = {};
if (isSingle) {
if (schema.isSingle) {
return await this.#getSingle(schemaName);
}
if (fields !== '*' && typeof fields === 'string') {
if (typeof fields === 'string') {
fields = [fields];
}
if (fields === undefined) {
fields = schema.fields.map((f) => f.fieldname);
}
/**
* Separate table fields and non table fields
*/
const allTableFields = this.#getTableFields(schemaName);
const allTableFieldNames = allTableFields.map((f) => f.fieldname);
let tableFields: TargetField[] = [];
let nonTableFieldNames: string[] = [];
if (Array.isArray(fields)) {
tableFields = tableFields.filter((f) => fields.includes(f.fieldname));
nonTableFieldNames = fields.filter(
(f) => !allTableFieldNames.includes(f)
);
} else if (fields === '*') {
tableFields = allTableFields;
}
const allTableFields: TargetField[] = this.#getTableFields(schemaName);
const allTableFieldNames: string[] = allTableFields.map((f) => f.fieldname);
const tableFields: TargetField[] = allTableFields.filter((f) =>
fields!.includes(f.fieldname)
);
const nonTableFieldNames: string[] = fields.filter(
(f) => !allTableFieldNames.includes(f)
);
/**
* If schema is not single then return specific fields
* if child fields are selected, all child fields are returned.
*/
if (nonTableFieldNames.length) {
fieldValueMap = (await this.#getOne(schemaName, name, fields)) ?? {};
fieldValueMap =
(await this.#getOne(schemaName, name, nonTableFieldNames)) ?? {};
}
if (tableFields.length) {
@ -194,32 +201,34 @@ export default class DatabaseCore {
return fieldValueMap;
}
async getAll({
schemaName,
fields,
filters,
start,
limit,
groupBy,
orderBy = 'created',
order = 'desc',
}: GetAllOptions): Promise<FieldValueMap[]> {
async getAll(
schemaName: string,
options: GetAllOptions = {}
): Promise<FieldValueMap[]> {
const schema = this.schemaMap[schemaName];
if (!fields) {
fields = ['name', ...(schema.keywordFields ?? [])];
}
if (typeof fields === 'string') {
fields = [fields];
}
return (await this.#getQueryBuilder(schemaName, fields, filters ?? {}, {
offset: start,
const hasCreated = !!schema.fields.find((f) => f.fieldname === 'created');
const {
fields = ['name', ...(schema.keywordFields ?? [])],
filters,
offset,
limit,
groupBy,
orderBy,
order,
})) as FieldValueMap[];
orderBy = hasCreated ? 'created' : undefined,
order = 'desc',
} = options;
return (await this.#getQueryBuilder(
schemaName,
typeof fields === 'string' ? [fields] : fields,
filters ?? {},
{
offset,
limit,
groupBy,
orderBy,
order,
}
)) as FieldValueMap[];
}
async getSingleValues(
@ -258,6 +267,11 @@ export default class DatabaseCore {
}
async rename(schemaName: string, oldName: string, newName: string) {
/**
* Rename is expensive mostly won't allow it.
* TODO: rename all links
* TODO: rename in childtables
*/
await this.knex!(schemaName)
.update({ name: newName })
.where('name', oldName);
@ -621,7 +635,7 @@ export default class DatabaseCore {
if (!child.name) {
child.name = getRandomString();
}
child.parentName = parentName;
child.parent = parentName;
child.parentSchemaName = parentSchemaName;
child.parentFieldname = field.fieldname;
child.idx = idx;
@ -663,8 +677,7 @@ export default class DatabaseCore {
tableFields: TargetField[]
) {
for (const field of tableFields) {
fieldValueMap[field.fieldname] = await this.getAll({
schemaName: field.target,
fieldValueMap[field.fieldname] = await this.getAll(field.target, {
fields: ['*'],
filters: { parent: fieldValueMap.name as string },
orderBy: 'idx',
@ -673,11 +686,7 @@ export default class DatabaseCore {
}
}
async #getOne(
schemaName: string,
name: string,
fields: string | string[] = '*'
) {
async #getOne(schemaName: string, name: string, fields: string[]) {
const fieldValueMap: FieldValueMap = await this.knex!.select(fields)
.from(schemaName)
.where('name', name)
@ -686,8 +695,7 @@ export default class DatabaseCore {
}
async #getSingle(schemaName: string): Promise<FieldValueMap> {
const values = await this.getAll({
schemaName: 'SingleValue',
const values = await this.getAll('SingleValue', {
fields: ['fieldname', 'value'],
filters: { parent: schemaName },
orderBy: 'fieldname',
@ -698,21 +706,21 @@ export default class DatabaseCore {
}
#insertOne(schemaName: string, fieldValueMap: FieldValueMap) {
const fields = this.schemaMap[schemaName].fields;
if (!fieldValueMap.name) {
fieldValueMap.name = getRandomString();
}
const validMap: FieldValueMap = {};
for (const { fieldname, fieldtype } of fields) {
if (fieldtype === FieldTypeEnum.Table) {
continue;
}
// Non Table Fields
const fields = this.schemaMap[schemaName].fields.filter(
(f) => f.fieldtype !== FieldTypeEnum.Table
);
const validMap: FieldValueMap = {};
for (const { fieldname } of fields) {
validMap[fieldname] = fieldValueMap[fieldname];
}
return this.knex!(schemaName).insert(fieldValueMap);
return this.knex!(schemaName).insert(validMap);
}
async #updateSingleValues(
@ -808,6 +816,18 @@ export default class DatabaseCore {
async #updateOne(schemaName: string, fieldValueMap: FieldValueMap) {
const updateMap = { ...fieldValueMap };
delete updateMap.name;
const schema = this.schemaMap[schemaName];
for (const { fieldname, fieldtype } of schema.fields) {
if (fieldtype !== FieldTypeEnum.Table) {
continue;
}
delete updateMap[fieldname];
}
if (Object.keys(updateMap).length === 0) {
return;
}
return await this.knex!(schemaName)
.where('name', fieldValueMap.name as string)

View File

@ -1,3 +1,4 @@
import assert from 'assert';
import { cloneDeep } from 'lodash';
import { SchemaMap, SchemaStub, SchemaStubMap } from 'schemas/types';
import {
@ -15,7 +16,6 @@ const Customer = {
fieldname: 'name',
label: 'Name',
fieldtype: 'Data',
default: 'John Thoe',
required: true,
},
{
@ -50,13 +50,13 @@ const SalesInvoiceItem = {
{
fieldname: 'rate',
label: 'Rate',
fieldtype: 'Currency',
fieldtype: 'Float',
required: true,
},
{
fieldname: 'amount',
label: 'Amount',
fieldtype: 'Currency',
fieldtype: 'Float',
computed: true,
readOnly: true,
},
@ -170,4 +170,39 @@ export function getBaseMeta() {
created: new Date().toISOString(),
modified: new Date().toISOString(),
};
}
}
export async function assertThrows(
func: () => Promise<unknown>,
message?: string
) {
let threw = true;
try {
await func();
threw = false;
} catch {
} finally {
if (!threw) {
throw new assert.AssertionError({
message: `Missing expected exception: ${message}`,
});
}
}
}
export async function assertDoesNotThrow(
func: () => Promise<unknown>,
message?: string
) {
try {
await func();
} catch (err) {
throw new assert.AssertionError({
message: `Missing expected exception: ${message} Error: ${
(err as Error).message
}`,
});
}
}
export type BaseMetaKey = 'created' | 'modified' | 'createdBy' | 'modifiedBy';

View File

@ -2,13 +2,29 @@ import * as assert from 'assert';
import 'mocha';
import { getMapFromList } from 'schemas/helpers';
import { FieldTypeEnum, RawValue } from 'schemas/types';
import { getValueMapFromList } from 'utils';
import { sqliteTypeMap } from '../../common';
import { getValueMapFromList, sleep } from 'utils';
import { getDefaultMetaFieldValueMap, sqliteTypeMap } from '../../common';
import DatabaseCore from '../core';
import { SqliteTableInfo } from '../types';
import { getBuiltTestSchemaMap } from './helpers';
import { FieldValueMap, SqliteTableInfo } from '../types';
import {
assertDoesNotThrow,
assertThrows,
BaseMetaKey,
getBuiltTestSchemaMap,
} from './helpers';
describe('DatabaseCore: Connect Migrate Close', async function () {
/**
* Note: these tests have a strange structure where multiple tests are
* inside a `specify`, this is cause `describe` doesn't support `async` or waiting
* on promises.
*
* Due to this `async` db operations need to be handled in `specify`. And `specify`
* can't be nested in the `describe` can, hence the strange structure.
*
* This also implies that assert calls should have discriptive
*/
describe('DatabaseCore: Connect Migrate Close', function () {
const db = new DatabaseCore();
specify('dbPath', function () {
assert.strictEqual(db.dbPath, ':memory:');
@ -98,7 +114,7 @@ describe('DatabaseCore: Migrate and Check Db', function () {
assert.strictEqual(
!!column.notnull,
field.required,
`${schemaName}.${column.name}:: notnull check: ${column.notnull}, ${field.required}`
`${schemaName}.${column.name}:: iotnull iheck: ${column.notnull}, ${field.required}`
);
} else {
assert.strictEqual(
@ -189,7 +205,7 @@ describe('DatabaseCore: CRUD', function () {
localeRow = rows.find((r) => r.fieldname === 'locale');
assert.notStrictEqual(localeEntryName, undefined, 'localeEntryName');
assert.strictEqual(rows.length, 2, 'row length');
assert.strictEqual(rows.length, 2, 'rows length insert');
assert.strictEqual(
localeRow?.name as string,
localeEntryName,
@ -215,7 +231,7 @@ describe('DatabaseCore: CRUD', function () {
localeRow = rows.find((r) => r.fieldname === 'locale');
assert.notStrictEqual(localeEntryName, undefined, 'localeEntryName');
assert.strictEqual(rows.length, 2, 'row length');
assert.strictEqual(rows.length, 2, 'rows length update');
assert.strictEqual(
localeRow?.name as string,
localeEntryName,
@ -276,5 +292,303 @@ describe('DatabaseCore: CRUD', function () {
}
});
specify('CRUD simple nondependent schema', async function () {});
specify('CRUD nondependent schema', async function () {
const schemaName = 'Customer';
let rows = await db.knex!(schemaName);
assert.strictEqual(rows.length, 0, 'rows length before insertion');
/**
* Insert
*/
const metaValues = getDefaultMetaFieldValueMap();
const name = 'John Thoe';
await assertThrows(
async () => await db.insert(schemaName, { name }),
'insert() did not throw without meta values'
);
const updateMap = Object.assign({}, metaValues, { name });
await db.insert(schemaName, updateMap);
rows = await db.knex!(schemaName);
let firstRow = rows?.[0];
assert.strictEqual(rows.length, 1, `rows length insert ${rows.length}`);
assert.strictEqual(
firstRow.name,
name,
`name check ${firstRow.name}, ${name}`
);
assert.strictEqual(firstRow.email, null, `email check ${firstRow.email}`);
for (const key in metaValues) {
assert.strictEqual(
firstRow[key],
metaValues[key as BaseMetaKey],
`${key} check`
);
}
/**
* Update
*/
const email = 'john@thoe.com';
await sleep(1); // required for modified to change
await db.update(schemaName, {
name,
email,
modified: new Date().toISOString(),
});
rows = await db.knex!(schemaName);
firstRow = rows?.[0];
assert.strictEqual(rows.length, 1, `rows length update ${rows.length}`);
assert.strictEqual(
firstRow.name,
name,
`name check update ${firstRow.name}, ${name}`
);
assert.strictEqual(
firstRow.email,
email,
`email check update ${firstRow.email}`
);
for (const key in metaValues) {
const val = firstRow[key];
const expected = metaValues[key as BaseMetaKey];
if (key !== 'modified') {
assert.strictEqual(val, expected, `${key} check ${val}, ${expected}`);
} else {
assert.notStrictEqual(
val,
expected,
`${key} check ${val}, ${expected}`
);
}
}
/**
* Delete
*/
await db.delete(schemaName, name);
rows = await db.knex!(schemaName);
assert.strictEqual(rows.length, 0, `rows length delete ${rows.length}`);
/**
* Get
*/
let fvMap = await db.get(schemaName, name);
assert.strictEqual(
Object.keys(fvMap).length,
0,
`key count get ${JSON.stringify(fvMap)}`
);
/**
* > 1 entries
*/
const cOne = { name: 'John Whoe', ...getDefaultMetaFieldValueMap() };
const cTwo = { name: 'Jane Whoe', ...getDefaultMetaFieldValueMap() };
// Insert
await db.insert(schemaName, cOne);
assert.strictEqual(
(await db.knex!(schemaName)).length,
1,
`rows length minsert`
);
await db.insert(schemaName, cTwo);
rows = await db.knex!(schemaName);
assert.strictEqual(rows.length, 2, `rows length minsert`);
const cs = [cOne, cTwo];
for (const i in cs) {
for (const k in cs[i]) {
const val = cs[i][k as BaseMetaKey];
assert.strictEqual(
rows?.[i]?.[k],
val,
`equality check ${i} ${k} ${val} ${rows?.[i]?.[k]}`
);
}
}
// Update
await db.update(schemaName, { name: cOne.name, email });
const cOneEmail = await db.get(schemaName, cOne.name, 'email');
assert.strictEqual(
cOneEmail.email,
email,
`mi update check one ${cOneEmail}`
);
const cTwoEmail = await db.get(schemaName, cTwo.name, 'email');
assert.strictEqual(
cOneEmail.email,
email,
`mi update check two ${cTwoEmail}`
);
// Rename
const newName = 'Johnny Whoe';
await db.rename(schemaName, cOne.name, newName);
fvMap = await db.get(schemaName, cOne.name);
assert.strictEqual(
Object.keys(fvMap).length,
0,
`mi rename check old ${JSON.stringify(fvMap)}`
);
fvMap = await db.get(schemaName, newName);
assert.strictEqual(
fvMap.email,
email,
`mi rename check new ${JSON.stringify(fvMap)}`
);
// Delete
await db.delete(schemaName, newName);
rows = await db.knex!(schemaName);
assert.strictEqual(rows.length, 1, `mi delete length ${rows.length}`);
assert.strictEqual(
rows[0].name,
cTwo.name,
`mi delete name ${rows[0].name}`
);
});
specify('CRUD dependent schema', async function () {
const Customer = 'Customer';
const SalesInvoice = 'SalesInvoice';
const SalesInvoiceItem = 'SalesInvoiceItem';
const customer: FieldValueMap = {
name: 'John Whoe',
email: 'john@whoe.com',
...getDefaultMetaFieldValueMap(),
};
const invoice: FieldValueMap = {
name: 'SINV-1001',
date: '2022-01-21',
customer: customer.name,
account: 'Debtors',
submitted: false,
cancelled: false,
...getDefaultMetaFieldValueMap(),
};
await assertThrows(
async () => await db.insert(SalesInvoice, invoice),
'foreign key constraint fail failed'
);
await assertDoesNotThrow(async () => {
await db.insert(Customer, customer);
await db.insert(SalesInvoice, invoice);
}, 'insertion failed');
await assertThrows(
async () => await db.delete(Customer, customer.name as string),
'foreign key constraint fail failed'
);
await assertDoesNotThrow(async () => {
await db.delete(SalesInvoice, invoice.name as string);
await db.delete(Customer, customer.name as string);
}, 'deletion failed');
await db.insert(Customer, customer);
await db.insert(SalesInvoice, invoice);
let fvMap = await db.get(SalesInvoice, invoice.name as string);
for (const key in invoice) {
let expected = invoice[key];
if (typeof expected === 'boolean') {
expected = +expected;
}
assert.strictEqual(
fvMap[key],
expected,
`equality check ${key}: ${fvMap[key]}, ${invoice[key]}`
);
}
assert.strictEqual(
(fvMap.items as unknown[])?.length,
0,
'empty items check'
);
const items: FieldValueMap[] = [
{
item: 'Bottle Caps',
quantity: 2,
rate: 100,
amount: 200,
},
];
await assertThrows(
async () => await db.insert(SalesInvoice, { name: invoice.name, items }),
'invoice insertion with ct did not fail'
);
await assertDoesNotThrow(
async () => await db.update(SalesInvoice, { name: invoice.name, items }),
'ct insertion failed'
);
fvMap = await db.get(SalesInvoice, invoice.name as string);
const ct = fvMap.items as FieldValueMap[];
assert.strictEqual(ct.length, 1, `ct length ${ct.length}`);
assert.strictEqual(ct[0].parent, invoice.name, `ct parent ${ct[0].parent}`);
assert.strictEqual(
ct[0].parentFieldname,
'items',
`ct parentFieldname ${ct[0].parentFieldname}`
);
assert.strictEqual(
ct[0].parentSchemaName,
SalesInvoice,
`ct parentSchemaName ${ct[0].parentSchemaName}`
);
for (const key in items[0]) {
assert.strictEqual(
ct[0][key],
items[0][key],
`ct values ${key}: ${ct[0][key]}, ${items[0][key]}`
);
}
items.push({
item: 'Mentats',
quantity: 4,
rate: 200,
amount: 800,
});
await assertDoesNotThrow(
async () => await db.update(SalesInvoice, { name: invoice.name, items }),
'ct updation failed'
);
let rows = await db.getAll(SalesInvoiceItem, {
fields: ['item', 'quantity', 'rate', 'amount'],
});
assert.strictEqual(rows.length, 2, `ct length update ${rows.length}`);
for (const i in rows) {
for (const key in rows[i]) {
assert.strictEqual(
rows[i][key],
items[i][key],
`ct values ${i},${key}: ${rows[i][key]}`
);
}
}
await db.delete(SalesInvoice, invoice.name as string);
rows = await db.getAll(SalesInvoiceItem);
assert.strictEqual(rows.length, 0, `ct length delete ${rows.length}`);
});
});

View File

@ -12,10 +12,9 @@ export interface GetQueryBuilderOptions {
}
export interface GetAllOptions {
schemaName: string;
fields?: string[];
filters?: QueryFilter;
start?: number;
offset?: number;
limit?: number;
groupBy?: string;
orderBy?: string;

View File

@ -27,3 +27,7 @@ export function getValueMapFromList<T, K extends keyof T, V extends keyof T>(
export function getRandomString(): string {
return Math.random().toString(36).slice(2, 8);
}
export async function sleep(durationMilliseconds: number = 1000) {
return new Promise((r) => setTimeout(() => r(null), durationMilliseconds));
}