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

View File

@@ -1,19 +1,13 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
afterEach,
beforeEach,
describe,
expect,
test as t,
type TestAPI,
} from 'vitest';
import { import {
createORMClientType, createORMClient,
type DBSchemaBuilder, type DBSchemaBuilder,
type Entity, type Entity,
f, f,
MemoryORMAdapter, MemoryORMAdapter,
type ORMClient,
} from '../'; } from '../';
const TEST_SCHEMA = { const TEST_SCHEMA = {
@@ -29,30 +23,23 @@ const TEST_SCHEMA = {
}, },
} satisfies DBSchemaBuilder; } 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 = { type Context = {
client: InstanceType<typeof Client>; client: ORMClient<typeof TEST_SCHEMA>;
}; };
beforeEach<Context>(async t => { beforeEach<Context>(async t => {
t.client = new Client(new MemoryORMAdapter()); t.client = createORMClient(TEST_SCHEMA, MemoryORMAdapter);
await t.client.connect();
});
afterEach<Context>(async t => { // define the hooks
await t.client.disconnect(); 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>; const test = t as TestAPI<Context>;

View File

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

View File

@@ -14,9 +14,10 @@ import { DocEngine } from '../../../sync';
import { MiniSyncServer } from '../../../sync/doc/__tests__/utils'; import { MiniSyncServer } from '../../../sync/doc/__tests__/utils';
import { MemoryStorage } from '../../../sync/doc/storage'; import { MemoryStorage } from '../../../sync/doc/storage';
import { import {
createORMClientType, createORMClient,
type DBSchemaBuilder, type DBSchemaBuilder,
f, f,
type ORMClient,
YjsDBAdapter, YjsDBAdapter,
} from '../'; } from '../';
@@ -29,27 +30,14 @@ const TEST_SCHEMA = {
}, },
} satisfies DBSchemaBuilder; } 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 = { type Context = {
server: MiniSyncServer; server: MiniSyncServer;
user1: { user1: {
client: InstanceType<typeof Client>; client: ORMClient<typeof TEST_SCHEMA>;
engine: DocEngine; engine: DocEngine;
}; };
user2: { user2: {
client: InstanceType<typeof Client>; client: ORMClient<typeof TEST_SCHEMA>;
engine: DocEngine; engine: DocEngine;
}; };
}; };
@@ -60,16 +48,25 @@ function createEngine(server: MiniSyncServer) {
async function createClient(server: MiniSyncServer, clientId: number) { async function createClient(server: MiniSyncServer, clientId: number) {
const engine = createEngine(server); const engine = createEngine(server);
const client = new Client( const client = createORMClient(TEST_SCHEMA, YjsDBAdapter, {
new YjsDBAdapter({ getDoc(guid: string) {
getDoc(guid: string) { const doc = new Doc({ guid });
const doc = new Doc({ guid }); doc.clientID = clientId;
doc.clientID = clientId; engine.addDoc(doc);
engine.addDoc(doc); return 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 { return {
engine, engine,
@@ -85,14 +82,10 @@ beforeEach<Context>(async t => {
t.user2 = await createClient(t.server, 2); t.user2 = await createClient(t.server, 2);
t.user1.engine.start(); t.user1.engine.start();
await t.user1.client.connect();
t.user2.engine.start(); t.user2.engine.start();
await t.user2.client.connect();
}); });
afterEach<Context>(async t => { afterEach<Context>(async t => {
t.user1.client.disconnect();
t.user2.client.disconnect();
t.user1.engine.stop(); t.user1.engine.stop();
t.user2.engine.stop(); t.user2.engine.stop();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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