@dxos/echo-db 2.31.2-dev.8a81d3db → 2.31.2-dev.ab9a89e2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.d.ts.map +1 -1
- package/dist/src/api/index.js +1 -0
- package/dist/src/api/index.js.map +1 -1
- package/dist/src/api/schema.d.ts +32 -0
- package/dist/src/api/schema.d.ts.map +1 -0
- package/dist/src/api/schema.js +72 -0
- package/dist/src/api/schema.js.map +1 -0
- package/dist/src/api/schema.test.d.ts +2 -0
- package/dist/src/api/schema.test.d.ts.map +1 -0
- package/dist/src/api/schema.test.js +157 -0
- package/dist/src/api/schema.test.js.map +1 -0
- package/dist/src/testing/testing.d.ts +27 -0
- package/dist/src/testing/testing.d.ts.map +1 -1
- package/dist/src/testing/testing.js +87 -3
- package/dist/src/testing/testing.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -17
- package/src/api/index.ts +1 -0
- package/src/api/schema.test.ts +179 -0
- package/src/api/schema.ts +100 -0
- package/src/testing/testing.ts +93 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2022 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import columnify from 'columnify';
|
|
7
|
+
import expect from 'expect';
|
|
8
|
+
import faker from 'faker';
|
|
9
|
+
|
|
10
|
+
import { truncate, truncateKey } from '@dxos/debug';
|
|
11
|
+
import { ObjectModel } from '@dxos/object-model';
|
|
12
|
+
|
|
13
|
+
import { createData, createSchemas, log, SchemaDefWithGenerator, setup } from '../testing';
|
|
14
|
+
import { Database } from './database';
|
|
15
|
+
import { Item } from './item';
|
|
16
|
+
import { Schema, SchemaField, TYPE_SCHEMA } from './schema';
|
|
17
|
+
|
|
18
|
+
enum TestType {
|
|
19
|
+
Org = 'example:type/org',
|
|
20
|
+
Person = 'example:type/person'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const schemaDefs: { [schema: string]: SchemaDefWithGenerator } = {
|
|
24
|
+
[TestType.Org]: {
|
|
25
|
+
schema: 'example:type/schema/organization',
|
|
26
|
+
fields: [
|
|
27
|
+
{
|
|
28
|
+
key: 'title',
|
|
29
|
+
required: true,
|
|
30
|
+
generator: () => faker.company.companyName()
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'website',
|
|
34
|
+
required: false,
|
|
35
|
+
generator: () => faker.internet.url()
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'collaborators',
|
|
39
|
+
required: false,
|
|
40
|
+
generator: () => faker.datatype.number().toString()
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
[TestType.Person]: {
|
|
45
|
+
schema: 'example:type/schema/person',
|
|
46
|
+
fields: [
|
|
47
|
+
{
|
|
48
|
+
key: 'title',
|
|
49
|
+
required: true,
|
|
50
|
+
generator: () => `${faker.name.firstName()} ${faker.name.lastName()}`
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('Schemas', () => {
|
|
57
|
+
it('creation of Schema', async () => setup(async (database) => {
|
|
58
|
+
const [schema] = await createSchemas(database, [schemaDefs[TestType.Org]]);
|
|
59
|
+
expect(schema.schema).toBe(schemaDefs[TestType.Org].schema);
|
|
60
|
+
expect(schema.fields[0].key).toBe('title');
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
it('add Schema field', async () => setup(async (database) => {
|
|
64
|
+
const [schema] = await createSchemas(database, [schemaDefs[TestType.Org]]);
|
|
65
|
+
|
|
66
|
+
const newField: SchemaField = {
|
|
67
|
+
key: 'location',
|
|
68
|
+
required: true
|
|
69
|
+
};
|
|
70
|
+
await schema.addField(newField);
|
|
71
|
+
|
|
72
|
+
expect(schema.getField('location')).toBeTruthy();
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
it('add Schema linked field', async () => setup(async (database) => {
|
|
76
|
+
const [orgSchema, personSchema] = await createSchemas(database, Object.values(schemaDefs));
|
|
77
|
+
|
|
78
|
+
const fieldRef: SchemaField = {
|
|
79
|
+
key: 'organization',
|
|
80
|
+
required: false,
|
|
81
|
+
ref: {
|
|
82
|
+
schema: orgSchema.schema,
|
|
83
|
+
field: orgSchema.fields[0].key
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
await personSchema.addField(fieldRef);
|
|
87
|
+
|
|
88
|
+
await createData(database, Object.values(schemaDefs), {
|
|
89
|
+
[schemaDefs[TestType.Org].schema]: 8,
|
|
90
|
+
[schemaDefs[TestType.Person].schema]: 16
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const items = await database.select().exec().entities;
|
|
94
|
+
|
|
95
|
+
[orgSchema, personSchema].forEach(schema => {
|
|
96
|
+
items.forEach(item => {
|
|
97
|
+
expect(schema.validate(item.model)).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
it('Use schema to validate the fields of an item', () => setup(async (database) => {
|
|
103
|
+
await createSchemas(database, Object.values(schemaDefs));
|
|
104
|
+
await createData(database, Object.values(schemaDefs), {
|
|
105
|
+
[schemaDefs[TestType.Org].schema]: 8,
|
|
106
|
+
[schemaDefs[TestType.Person].schema]: 16
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { entities: schemas } = database
|
|
110
|
+
.select({ type: TYPE_SCHEMA })
|
|
111
|
+
.exec();
|
|
112
|
+
|
|
113
|
+
const { entities: orgs } = database
|
|
114
|
+
.select({ type: TestType.Org })
|
|
115
|
+
.exec();
|
|
116
|
+
|
|
117
|
+
const { entities: people } = database
|
|
118
|
+
.select({ type: TestType.Person })
|
|
119
|
+
.exec();
|
|
120
|
+
|
|
121
|
+
[...orgs, ...people].forEach(item => {
|
|
122
|
+
const schemaItem = schemas.find(schema => schema.model.get('schema') === item.type);
|
|
123
|
+
const schema = new Schema(schemaItem!.model);
|
|
124
|
+
expect(schema.validate(item.model)).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Log tables.
|
|
128
|
+
schemas.forEach(schema => {
|
|
129
|
+
const type = schema.model.get('schema');
|
|
130
|
+
const { entities: items } = database.select({ type }).exec();
|
|
131
|
+
log(renderItems(schema, items, database));
|
|
132
|
+
});
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Log the items for the given schema.
|
|
138
|
+
* @param schema
|
|
139
|
+
* @param items
|
|
140
|
+
* @param [party]
|
|
141
|
+
*/
|
|
142
|
+
const renderItems = (schema: Item<ObjectModel>, items: Item<ObjectModel>[], database?: Database) => {
|
|
143
|
+
const fields = Object.values(schema.model.get('fields')) as SchemaField[];
|
|
144
|
+
const columns = fields.map(({ key }) => key);
|
|
145
|
+
|
|
146
|
+
const logKey = (id: string) => truncateKey(id, 4);
|
|
147
|
+
const logString = (value: string) => truncate(value, 24, true);
|
|
148
|
+
|
|
149
|
+
const values = items.map(item => {
|
|
150
|
+
return fields.reduce<{ [key: string]: any }>((row, { key, type, ref }) => {
|
|
151
|
+
const value = item.model.get(key);
|
|
152
|
+
switch (type) {
|
|
153
|
+
case 'string': {
|
|
154
|
+
row[key] = chalk.green(logString(value));
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'ref': {
|
|
159
|
+
if (database) {
|
|
160
|
+
const { field } = ref!;
|
|
161
|
+
const item = database.getItem(value);
|
|
162
|
+
row[key] = chalk.red(logString(item?.model.get(field)));
|
|
163
|
+
} else {
|
|
164
|
+
row[key] = chalk.red(logKey(value));
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
default: {
|
|
170
|
+
row[key] = value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return row;
|
|
175
|
+
}, { id: chalk.blue(logKey(item.id)) });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return columnify(values, { columns: ['id', ...columns] });
|
|
179
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2022 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { ObjectModel } from '@dxos/object-model';
|
|
6
|
+
|
|
7
|
+
export const TYPE_SCHEMA = 'dxos:type/schema';
|
|
8
|
+
|
|
9
|
+
export type FieldType = 'string' | 'number' | 'boolean' | 'ref'
|
|
10
|
+
|
|
11
|
+
// TODO(burdon): Protobuf definitions.
|
|
12
|
+
|
|
13
|
+
export type SchemaRef = {
|
|
14
|
+
schema: string
|
|
15
|
+
field: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SchemaField = {
|
|
19
|
+
key: string
|
|
20
|
+
type?: FieldType
|
|
21
|
+
required: boolean
|
|
22
|
+
ref?: SchemaRef
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SchemaDef = {
|
|
26
|
+
schema: string
|
|
27
|
+
fields: SchemaField[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wrapper for ECHO Item that represents an `ObjectModel` schema.
|
|
32
|
+
*/
|
|
33
|
+
export class Schema {
|
|
34
|
+
constructor (
|
|
35
|
+
private readonly _schema: ObjectModel
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
get schema (): string {
|
|
39
|
+
return this._schema.get('schema');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get fields (): SchemaField[] {
|
|
43
|
+
return Object.values(this._schema.get('fields') ?? {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getField (key: string): SchemaField | undefined {
|
|
47
|
+
return this.fields.find(field => field.key === key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// TODO(kaplanski): What happens if an item has extra properties?
|
|
51
|
+
validate (model: ObjectModel) {
|
|
52
|
+
this.fields.forEach(field => {
|
|
53
|
+
const value = model.get(field.key);
|
|
54
|
+
if (field.required) {
|
|
55
|
+
if (!value) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (field.type) {
|
|
61
|
+
if (typeof value !== field.type) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (field.ref) {
|
|
67
|
+
// TODO(kaplanski): Should this class have access to all items in the party to validate?
|
|
68
|
+
// Or maybe possible values should be provided?
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// TODO(kaplanski): Should the field be added to each item using the schema in the party? (Empty value?)
|
|
75
|
+
// TODO(kaplanski): Should the type be infered from the first value added?
|
|
76
|
+
async addField (newField: SchemaField) {
|
|
77
|
+
const newFields = [
|
|
78
|
+
...this.fields,
|
|
79
|
+
newField
|
|
80
|
+
];
|
|
81
|
+
// TODO(kaplanski): Create a SET mutation to just modify the field (not all fields).
|
|
82
|
+
await this._schema.set('fields', newFields);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// TODO(kaplanski): Should editing a field modify all existing items using this schema?
|
|
86
|
+
async editField (currentKey: string, editedField: SchemaField) {
|
|
87
|
+
const newFields = this.fields.map(field => {
|
|
88
|
+
if (field.key === currentKey) {
|
|
89
|
+
return editedField;
|
|
90
|
+
}
|
|
91
|
+
return field;
|
|
92
|
+
});
|
|
93
|
+
await this._schema.set('fields', newFields);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async deleteField (key: string) {
|
|
97
|
+
const newFields = this.fields.filter(field => field.key !== key);
|
|
98
|
+
await this._schema.set('fields', newFields);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/testing/testing.ts
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import debug from 'debug';
|
|
6
|
+
import faker from 'faker';
|
|
6
7
|
|
|
7
8
|
import { createKeyPair } from '@dxos/crypto';
|
|
9
|
+
import { ModelFactory } from '@dxos/model-factory';
|
|
8
10
|
import { NetworkManagerOptions } from '@dxos/network-manager';
|
|
11
|
+
import { ObjectModel } from '@dxos/object-model';
|
|
9
12
|
import { IStorage } from '@dxos/random-access-multi-storage';
|
|
10
13
|
import { jsonReplacer } from '@dxos/util';
|
|
11
14
|
|
|
15
|
+
import { Database, Schema, SchemaDef, SchemaField, TYPE_SCHEMA } from '../api';
|
|
16
|
+
import { createInMemoryDatabase } from '../database';
|
|
12
17
|
import { ECHO } from '../echo';
|
|
13
18
|
import { PartyInternal } from '../parties';
|
|
14
19
|
import { createRamStorage } from '../util';
|
|
15
20
|
|
|
16
|
-
const log = debug('dxos:echo-db:testing');
|
|
21
|
+
export const log = debug('dxos:echo-db:testing');
|
|
17
22
|
|
|
18
23
|
export const messageLogger = (tag: string) => (message: any) => {
|
|
19
24
|
log(tag, JSON.stringify(message, jsonReplacer, 2));
|
|
@@ -79,3 +84,90 @@ export const inviteTestPeer = async (party: PartyInternal, peer: ECHO): Promise<
|
|
|
79
84
|
|
|
80
85
|
return peer.joinParty(invitation, async () => Buffer.from('0000'));
|
|
81
86
|
};
|
|
87
|
+
|
|
88
|
+
export type SchemaFieldWithGenerator = SchemaField & { generator: () => string }
|
|
89
|
+
export type SchemaDefWithGenerator = Omit<SchemaDef, 'fields'> & { fields: SchemaFieldWithGenerator[] };
|
|
90
|
+
|
|
91
|
+
type Callback = (party: Database) => Promise<void>
|
|
92
|
+
|
|
93
|
+
export const setup = async (callback: Callback) => {
|
|
94
|
+
const modelFactory = new ModelFactory().registerModel(ObjectModel);
|
|
95
|
+
const database = await createInMemoryDatabase(modelFactory);
|
|
96
|
+
try {
|
|
97
|
+
await callback(database);
|
|
98
|
+
} finally {
|
|
99
|
+
await database.destroy();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create schema items.
|
|
105
|
+
*/
|
|
106
|
+
export const createSchemas = async (database: Database, schemas: SchemaDefWithGenerator[]) => {
|
|
107
|
+
log(`Creating schemas: [${schemas.map(({ schema }) => schema).join()}]`);
|
|
108
|
+
|
|
109
|
+
const schemaItems = await Promise.all(schemas.map(({ schema, fields }) => {
|
|
110
|
+
const schemaFields = fields.map(fieldWithGenerator => {
|
|
111
|
+
// eslint-disable-next-line unused-imports/no-unused-vars
|
|
112
|
+
const { generator, ...field } = fieldWithGenerator;
|
|
113
|
+
return field;
|
|
114
|
+
}).flat();
|
|
115
|
+
|
|
116
|
+
return database.createItem({
|
|
117
|
+
model: ObjectModel,
|
|
118
|
+
type: TYPE_SCHEMA,
|
|
119
|
+
props: {
|
|
120
|
+
schema,
|
|
121
|
+
fields: schemaFields
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
return schemaItems.map(item => new Schema(item.model));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create items for a given schema.
|
|
131
|
+
* NOTE: Assumes that referenced items have already been constructed.
|
|
132
|
+
*/
|
|
133
|
+
export const createItems = async (database: Database, { schema, fields }: SchemaDefWithGenerator, numItems: number) => {
|
|
134
|
+
log(`Creating items for: ${schema}`);
|
|
135
|
+
|
|
136
|
+
return await Promise.all(Array.from({ length: numItems }).map(async () => {
|
|
137
|
+
const values = fields.map(field => {
|
|
138
|
+
if (field.ref) {
|
|
139
|
+
// Look-up item.
|
|
140
|
+
const { entities: items } = database.select().filter({ type: field.ref.schema }).exec();
|
|
141
|
+
if (items.length) {
|
|
142
|
+
return {
|
|
143
|
+
[field.key]: faker.random.arrayElement(items).id
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
return {
|
|
148
|
+
[field.key]: field.generator()
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return undefined;
|
|
153
|
+
}).filter(Boolean);
|
|
154
|
+
|
|
155
|
+
return await database.createItem({
|
|
156
|
+
type: schema,
|
|
157
|
+
props: Object.assign({}, ...values)
|
|
158
|
+
});
|
|
159
|
+
}));
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create data for all schemas.
|
|
164
|
+
*/
|
|
165
|
+
export const createData = async (database: Database, schemas: SchemaDefWithGenerator[], options: { [key: string]: number } = {}) => {
|
|
166
|
+
// Synchronous loop.
|
|
167
|
+
for (const schema of schemas) {
|
|
168
|
+
const count = options[schema.schema] ?? 0;
|
|
169
|
+
if (count) {
|
|
170
|
+
await createItems(database, schema, count);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|