feat(core): adjust orm api (#7392)

Removed the `connect` and `disconnect` functions on the orm `DBAdapter`, making the ORM completely non-asynchronous.
This commit is contained in:
EYHN 2024-07-01 09:32:15 +00:00
parent d72dbe682c
commit 39acb51d87
No known key found for this signature in database
GPG Key ID: 46C9E26A75AB276C
14 changed files with 111 additions and 241 deletions

View File

@ -1,4 +0,0 @@
import { createORMClientType } from '../core';
import { AFFiNE_DB_SCHEMA } from './schema';
export const ORMClient = createORMClientType(AFFiNE_DB_SCHEMA);

View File

@ -1,21 +0,0 @@
import { ORMClient } from './client';
// The ORM hooks are used to define the transformers that will be applied on entities when they are loaded from the data providers.
// All transformers are doing in memory, none of the data under the hood will be changed.
//
// for example:
// data in providers: { color: 'red' }
// hook: { color: 'red' } => { color: '#FF0000' }
//
// ORMClient.defineHook(
// 'demo',
// 'deprecate color field and introduce colors filed',
// {
// deserialize(tag) {
// tag.color = stringToHex(tag.color)
// return tag;
// },
// }
// );
export { ORMClient };

View File

@ -1,3 +0,0 @@
import './hooks';
export { ORMClient } from './client';

View File

@ -1,17 +0,0 @@
import type { DBSchemaBuilder } from '../core';
// import { f } from './core';
export const AFFiNE_DB_SCHEMA = {
// demo: {
// id: f.string().primaryKey().optional().default(nanoid),
// name: f.string(),
// // v1
// // color: f.string(),
// // v2, without data level breaking change
// /**
// * @deprecated use [colors]
// */
// color: f.string().optional(), // <= mark as optional since new created record might only have [colors] field
// colors: f.json<string[]>().optional(), // <= mark as optional since old records might only have [color] field
// },
} as const satisfies DBSchemaBuilder;

View File

@ -1,18 +1,12 @@
import { nanoid } from 'nanoid';
import {
afterEach,
beforeEach,
describe,
expect,
test as t,
type TestAPI,
} from 'vitest';
import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
import {
createORMClientType,
createORMClient,
type DBSchemaBuilder,
f,
MemoryORMAdapter,
type ORMClient,
Table,
} from '../';
@ -24,18 +18,12 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const Client = createORMClientType(TEST_SCHEMA);
type Context = {
client: InstanceType<typeof Client>;
client: ORMClient<typeof TEST_SCHEMA>;
};
beforeEach<Context>(async t => {
t.client = new Client(new MemoryORMAdapter());
await t.client.connect();
});
afterEach<Context>(async t => {
await t.client.disconnect();
t.client = createORMClient(TEST_SCHEMA, MemoryORMAdapter);
});
const test = t as TestAPI<Context>;

View File

@ -1,19 +1,13 @@
import { nanoid } from 'nanoid';
import {
afterEach,
beforeEach,
describe,
expect,
test as t,
type TestAPI,
} from 'vitest';
import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
import {
createORMClientType,
createORMClient,
type DBSchemaBuilder,
type Entity,
f,
MemoryORMAdapter,
type ORMClient,
} from '../';
const TEST_SCHEMA = {
@ -29,30 +23,23 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const Client = createORMClientType(TEST_SCHEMA);
// define the hooks
Client.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
type Context = {
client: InstanceType<typeof Client>;
client: ORMClient<typeof TEST_SCHEMA>;
};
beforeEach<Context>(async t => {
t.client = new Client(new MemoryORMAdapter());
await t.client.connect();
});
t.client = createORMClient(TEST_SCHEMA, MemoryORMAdapter);
afterEach<Context>(async t => {
await t.client.disconnect();
// define the hooks
t.client.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
});
const test = t as TestAPI<Context>;

View File

@ -1,12 +1,21 @@
import { nanoid } from 'nanoid';
import { describe, expect, test } from 'vitest';
import { createORMClientType, f, MemoryORMAdapter } from '../';
import {
createORMClient,
type DBSchemaBuilder,
f,
MemoryORMAdapter,
} from '../';
function createClient<Schema extends DBSchemaBuilder>(schema: Schema) {
return createORMClient(schema, MemoryORMAdapter);
}
describe('Schema validations', () => {
test('primary key must be set', () => {
expect(() =>
createORMClientType({
createClient({
tags: {
id: f.string(),
name: f.string(),
@ -19,7 +28,7 @@ describe('Schema validations', () => {
test('primary key must be unique', () => {
expect(() =>
createORMClientType({
createClient({
tags: {
id: f.string().primaryKey(),
name: f.string().primaryKey(),
@ -32,7 +41,7 @@ describe('Schema validations', () => {
test('primary key should not be optional without default value', () => {
expect(() =>
createORMClientType({
createClient({
tags: {
id: f.string().primaryKey().optional(),
name: f.string(),
@ -45,7 +54,7 @@ describe('Schema validations', () => {
test('primary key can be optional with default value', async () => {
expect(() =>
createORMClientType({
createClient({
tags: {
id: f.string().primaryKey().optional().default(nanoid),
name: f.string(),
@ -56,20 +65,18 @@ describe('Schema validations', () => {
});
describe('Entity validations', () => {
const Client = createORMClientType({
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
});
function createClient() {
return new Client(new MemoryORMAdapter());
function createTagsClient() {
return createClient({
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
});
}
test('should not update primary key', () => {
const client = createClient();
const client = createTagsClient();
const tag = client.tags.create({
name: 'tag',
@ -83,7 +90,7 @@ describe('Entity validations', () => {
});
test('should throw when trying to create entity with missing required field', () => {
const client = createClient();
const client = createTagsClient();
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test' })).toThrow(
@ -92,7 +99,7 @@ describe('Entity validations', () => {
});
test('should throw when trying to create entity with extra field', () => {
const client = createClient();
const client = createTagsClient();
expect(() =>
// @ts-expect-error test
@ -101,34 +108,28 @@ describe('Entity validations', () => {
});
test('should throw when trying to create entity with unexpected field type', () => {
const client = createClient();
const client = createTagsClient();
expect(() =>
// @ts-expect-error test
client.tags.create({ name: 'test', color: 123 })
).toThrow(
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test', color: 123 })).toThrow(
"[Table(tags)]: Field 'color' type mismatch. Expected type 'string' but got 'number'."
);
expect(() =>
// @ts-expect-error test
client.tags.create({ name: 'test', color: [123] })
).toThrow(
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test', color: [123] })).toThrow(
"[Table(tags)]: Field 'color' type mismatch. Expected type 'string' but got 'json'"
);
});
test('should be able to assign `null` to json field', () => {
expect(() => {
const Client = createORMClientType({
const client = createClient({
tags: {
id: f.string().primaryKey().default(nanoid),
info: f.json(),
},
});
const client = new Client(new MemoryORMAdapter());
const tag = client.tags.create({ info: null });
expect(tag.info).toBe(null);

View File

@ -14,9 +14,10 @@ import { DocEngine } from '../../../sync';
import { MiniSyncServer } from '../../../sync/doc/__tests__/utils';
import { MemoryStorage } from '../../../sync/doc/storage';
import {
createORMClientType,
createORMClient,
type DBSchemaBuilder,
f,
type ORMClient,
YjsDBAdapter,
} from '../';
@ -29,27 +30,14 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const Client = createORMClientType(TEST_SCHEMA);
// define the hooks
Client.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
type Context = {
server: MiniSyncServer;
user1: {
client: InstanceType<typeof Client>;
client: ORMClient<typeof TEST_SCHEMA>;
engine: DocEngine;
};
user2: {
client: InstanceType<typeof Client>;
client: ORMClient<typeof TEST_SCHEMA>;
engine: DocEngine;
};
};
@ -60,16 +48,25 @@ function createEngine(server: MiniSyncServer) {
async function createClient(server: MiniSyncServer, clientId: number) {
const engine = createEngine(server);
const client = new Client(
new YjsDBAdapter({
getDoc(guid: string) {
const doc = new Doc({ guid });
doc.clientID = clientId;
engine.addDoc(doc);
return doc;
},
})
);
const client = createORMClient(TEST_SCHEMA, YjsDBAdapter, {
getDoc(guid: string) {
const doc = new Doc({ guid });
doc.clientID = clientId;
engine.addDoc(doc);
return doc;
},
});
// define the hooks
client.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
return {
engine,
@ -85,14 +82,10 @@ beforeEach<Context>(async t => {
t.user2 = await createClient(t.server, 2);
t.user1.engine.start();
await t.user1.client.connect();
t.user2.engine.start();
await t.user2.client.connect();
});
afterEach<Context>(async t => {
t.user1.client.disconnect();
t.user2.client.disconnect();
t.user1.engine.stop();
t.user2.engine.stop();
});

View File

@ -1,20 +1,14 @@
import { nanoid } from 'nanoid';
import {
afterEach,
beforeEach,
describe,
expect,
test as t,
type TestAPI,
} from 'vitest';
import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
import { Doc } from 'yjs';
import {
createORMClientType,
createORMClient,
type DBSchemaBuilder,
type DocProvider,
type Entity,
f,
type ORMClient,
Table,
YjsDBAdapter,
} from '../';
@ -33,18 +27,12 @@ const docProvider: DocProvider = {
},
};
const Client = createORMClientType(TEST_SCHEMA);
type Context = {
client: InstanceType<typeof Client>;
client: ORMClient<typeof TEST_SCHEMA>;
};
beforeEach<Context>(async t => {
t.client = new Client(new YjsDBAdapter(docProvider));
await t.client.connect();
});
afterEach<Context>(async t => {
await t.client.disconnect();
t.client = createORMClient(TEST_SCHEMA, YjsDBAdapter, docProvider);
});
const test = t as TestAPI<Context>;
@ -223,15 +211,13 @@ describe('ORM entity CRUD', () => {
});
test('can not use reserved keyword as field name', () => {
const Client = createORMClientType({
const schema = {
tags: {
$$KEY: f.string().primaryKey().default(nanoid),
},
});
};
expect(() =>
new Client(new YjsDBAdapter(docProvider)).connect()
).rejects.toThrow(
expect(() => createORMClient(schema, YjsDBAdapter, docProvider)).toThrow(
"[Table(tags)]: Field '$$KEY' is reserved keyword and can't be used"
);
});

View File

@ -1,16 +1,7 @@
import type { DBSchemaBuilder } from '../../schema';
import type { DBAdapter } from '../types';
import { MemoryTableAdapter } from './table';
export class MemoryORMAdapter implements DBAdapter {
connect(_db: DBSchemaBuilder): Promise<void> {
return Promise.resolve();
}
disconnect(_db: DBSchemaBuilder): Promise<void> {
return Promise.resolve();
}
table(tableName: string) {
return new MemoryTableAdapter(tableName);
}

View File

@ -1,4 +1,4 @@
import type { DBSchemaBuilder, TableSchemaBuilder } from '../schema';
import type { TableSchemaBuilder } from '../schema';
export interface Key {
toString(): string;
@ -21,8 +21,5 @@ export interface TableAdapter<K extends Key = any, T = unknown> {
}
export interface DBAdapter {
connect(db: DBSchemaBuilder): Promise<void>;
disconnect(db: DBSchemaBuilder): Promise<void>;
table(tableName: string): TableAdapter;
}

View File

@ -11,25 +11,16 @@ export interface DocProvider {
export class YjsDBAdapter implements DBAdapter {
tables: Map<string, TableAdapter> = new Map();
constructor(private readonly provider: DocProvider) {}
connect(db: DBSchemaBuilder): Promise<void> {
constructor(
db: DBSchemaBuilder,
private readonly provider: DocProvider
) {
for (const [tableName, table] of Object.entries(db)) {
validators.validateYjsTableSchema(tableName, table);
const doc = this.provider.getDoc(tableName);
this.tables.set(tableName, new YjsTableAdapter(tableName, doc));
}
return Promise.resolve();
}
disconnect(_db: DBSchemaBuilder): Promise<void> {
this.tables.forEach(table => {
table.dispose();
});
this.tables.clear();
return Promise.resolve();
}
table(tableName: string) {

View File

@ -1,10 +1,10 @@
import { type DBAdapter, type Hook } from './adapters';
import type { DBSchemaBuilder } from './schema';
import { type CreateEntityInput, Table, type TableMap } from './table';
import { Table, type TableMap } from './table';
import { validators } from './validators';
export class ORMClient {
static hooksMap: Map<string, Hook<any>[]> = new Map();
class RawORMClient {
hooksMap: Map<string, Hook<any>[]> = new Map();
private readonly tables = new Map<string, Table<any>>();
constructor(
protected readonly db: DBSchemaBuilder,
@ -17,7 +17,7 @@ export class ORMClient {
if (!table) {
table = new Table(this.adapter, tableName, {
schema: tableSchema,
hooks: ORMClient.hooksMap.get(tableName),
hooks: this.hooksMap.get(tableName),
});
this.tables.set(tableName, table);
}
@ -27,7 +27,7 @@ export class ORMClient {
});
}
static defineHook(tableName: string, _desc: string, hook: Hook<any>) {
defineHook(tableName: string, _desc: string, hook: Hook<any>) {
let hooks = this.hooksMap.get(tableName);
if (!hooks) {
hooks = [];
@ -36,48 +36,30 @@ export class ORMClient {
hooks.push(hook);
}
async connect() {
await this.adapter.connect(this.db);
}
async disconnect() {
await this.adapter.disconnect(this.db);
}
}
export function createORMClientType<Schema extends DBSchemaBuilder>(
db: Schema
): ORMClientWithTablesClass<Schema> {
export function createORMClient<
const Schema extends DBSchemaBuilder,
AdapterConstructor extends new (...args: any[]) => DBAdapter,
AdapterConstructorParams extends
any[] = ConstructorParameters<AdapterConstructor> extends [
DBSchemaBuilder,
...infer Args,
]
? Args
: never,
>(
db: Schema,
adapter: AdapterConstructor,
...args: AdapterConstructorParams
): ORMClient<Schema> {
Object.entries(db).forEach(([tableName, schema]) => {
validators.validateTableSchema(tableName, schema);
});
class ORMClientWithTables extends ORMClient {
constructor(adapter: DBAdapter) {
super(db, adapter);
}
}
return ORMClientWithTables as {
new (
...args: ConstructorParameters<typeof ORMClientWithTables>
): ORMClient & TableMap<Schema>;
defineHook<TableName extends keyof Schema>(
tableName: TableName,
desc: string,
hook: Hook<CreateEntityInput<Schema[TableName]>>
): void;
};
return new RawORMClient(db, new adapter(db, ...args)) as TableMap<Schema> &
RawORMClient;
}
export type ORMClientWithTablesClass<Schema extends DBSchemaBuilder> = {
new (adapter: DBAdapter): TableMap<Schema> & ORMClient;
defineHook<TableName extends keyof Schema>(
tableName: TableName,
desc: string,
hook: Hook<CreateEntityInput<Schema[TableName]>>
): void;
};
export type ORMClient<Schema extends DBSchemaBuilder> = RawORMClient &
TableMap<Schema>;

View File

@ -1 +0,0 @@
export * from './affine';