feat(infra): improve orm (#7475)

This commit is contained in:
forehalo 2024-07-12 04:25:59 +00:00
parent 5dd7382693
commit 024e5500f6
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
13 changed files with 616 additions and 328 deletions

View File

@ -6,7 +6,6 @@ import {
type DBSchemaBuilder,
f,
MemoryORMAdapter,
type ORMClient,
Table,
} from '../';
@ -18,12 +17,14 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
type Context = {
client: ORMClient<typeof TEST_SCHEMA>;
client: InstanceType<typeof ORMClient>;
};
beforeEach<Context>(async t => {
t.client = createORMClient(TEST_SCHEMA, MemoryORMAdapter);
t.client = new ORMClient(new MemoryORMAdapter());
});
const test = t as TestAPI<Context>;
@ -94,7 +95,7 @@ describe('ORM entity CRUD', () => {
});
// old tag should not be updated
expect(tag.name).not.toBe(tag2.name);
expect(tag.name).not.toBe(tag2!.name);
});
test('should be able to delete entity', async t => {

View File

@ -7,7 +7,6 @@ import {
type Entity,
f,
MemoryORMAdapter,
type ORMClient,
} from '../';
const TEST_SCHEMA = {
@ -23,23 +22,25 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
// define the hooks
ORMClient.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
type Context = {
client: ORMClient<typeof TEST_SCHEMA>;
client: InstanceType<typeof ORMClient>;
};
beforeEach<Context>(async t => {
t.client = createORMClient(TEST_SCHEMA, MemoryORMAdapter);
// 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;
},
});
t.client = new ORMClient(new MemoryORMAdapter());
});
const test = t as TestAPI<Context>;
@ -65,7 +66,7 @@ describe('ORM hook mixin', () => {
});
const tag2 = client.tags.get(tag.id);
expect(tag2.colors).toStrictEqual(['red']);
expect(tag2!.colors).toStrictEqual(['red']);
});
test('update entity', t => {
@ -77,7 +78,7 @@ describe('ORM hook mixin', () => {
});
const tag2 = client.tags.update(tag.id, { color: 'blue' });
expect(tag2.colors).toStrictEqual(['blue']);
expect(tag2!.colors).toStrictEqual(['blue']);
});
test('subscribe entity', t => {

View File

@ -1,21 +1,12 @@
import { nanoid } from 'nanoid';
import { describe, expect, test } from 'vitest';
import {
createORMClient,
type DBSchemaBuilder,
f,
MemoryORMAdapter,
} from '../';
function createClient<Schema extends DBSchemaBuilder>(schema: Schema) {
return createORMClient(schema, MemoryORMAdapter);
}
import { createORMClient, f, MemoryORMAdapter } from '../';
describe('Schema validations', () => {
test('primary key must be set', () => {
expect(() =>
createClient({
createORMClient({
tags: {
id: f.string(),
name: f.string(),
@ -28,7 +19,7 @@ describe('Schema validations', () => {
test('primary key must be unique', () => {
expect(() =>
createClient({
createORMClient({
tags: {
id: f.string().primaryKey(),
name: f.string().primaryKey(),
@ -41,7 +32,7 @@ describe('Schema validations', () => {
test('primary key should not be optional without default value', () => {
expect(() =>
createClient({
createORMClient({
tags: {
id: f.string().primaryKey().optional(),
name: f.string(),
@ -54,7 +45,7 @@ describe('Schema validations', () => {
test('primary key can be optional with default value', async () => {
expect(() =>
createClient({
createORMClient({
tags: {
id: f.string().primaryKey().optional().default(nanoid),
name: f.string(),
@ -65,14 +56,16 @@ describe('Schema validations', () => {
});
describe('Entity validations', () => {
const Client = createORMClient({
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
});
function createTagsClient() {
return createClient({
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
});
return new Client(new MemoryORMAdapter());
}
test('should not update primary key', () => {
@ -123,13 +116,15 @@ describe('Entity validations', () => {
test('should be able to assign `null` to json field', () => {
expect(() => {
const client = createClient({
const Client = createORMClient({
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

@ -13,13 +13,7 @@ import { Doc } from 'yjs';
import { DocEngine } from '../../../sync';
import { MiniSyncServer } from '../../../sync/doc/__tests__/utils';
import { MemoryStorage } from '../../../sync/doc/storage';
import {
createORMClient,
type DBSchemaBuilder,
f,
type ORMClient,
YjsDBAdapter,
} from '../';
import { createORMClient, type DBSchemaBuilder, f, YjsDBAdapter } from '../';
const TEST_SCHEMA = {
tags: {
@ -30,14 +24,16 @@ const TEST_SCHEMA = {
},
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
type Context = {
server: MiniSyncServer;
user1: {
client: ORMClient<typeof TEST_SCHEMA>;
client: InstanceType<typeof ORMClient>;
engine: DocEngine;
};
user2: {
client: ORMClient<typeof TEST_SCHEMA>;
client: InstanceType<typeof ORMClient>;
engine: DocEngine;
};
};
@ -48,17 +44,10 @@ function createEngine(server: MiniSyncServer) {
async function createClient(server: MiniSyncServer, clientId: number) {
const engine = createEngine(server);
const client = createORMClient(TEST_SCHEMA, YjsDBAdapter, {
getDoc(guid: string) {
const doc = new Doc({ guid });
doc.clientID = clientId;
engine.addDoc(doc);
return doc;
},
});
const Client = createORMClient(TEST_SCHEMA);
// define the hooks
client.defineHook('tags', 'migrate field `color` to field `colors`', {
Client.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
@ -68,6 +57,17 @@ async function createClient(server: MiniSyncServer, clientId: number) {
},
});
const client = new Client(
new YjsDBAdapter(TEST_SCHEMA, {
getDoc(guid: string) {
const doc = new Doc({ guid });
doc.clientID = clientId;
engine.addDoc(doc);
return doc;
},
})
);
return {
engine,
client,

View File

@ -8,17 +8,25 @@ import {
type DocProvider,
type Entity,
f,
type ORMClient,
Table,
YjsDBAdapter,
} from '../';
function incremental() {
let i = 0;
return () => i++;
}
const TEST_SCHEMA = {
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
users: {
id: f.number().primaryKey().default(incremental()),
name: f.string(),
},
} satisfies DBSchemaBuilder;
const docProvider: DocProvider = {
@ -27,12 +35,13 @@ const docProvider: DocProvider = {
},
};
const Client = createORMClient(TEST_SCHEMA);
type Context = {
client: ORMClient<typeof TEST_SCHEMA>;
client: InstanceType<typeof Client>;
};
beforeEach<Context>(async t => {
t.client = createORMClient(TEST_SCHEMA, YjsDBAdapter, docProvider);
t.client = new Client(new YjsDBAdapter(TEST_SCHEMA, docProvider));
});
const test = t as TestAPI<Context>;
@ -55,6 +64,13 @@ describe('ORM entity CRUD', () => {
expect(tag.id).toBeDefined();
expect(tag.name).toBe('test');
expect(tag.color).toBe('red');
const user = client.users.create({
name: 'user1',
});
expect(typeof user.id).toBe('number');
expect(user.name).toBe('user1');
});
test('should be able to read entity', t => {
@ -67,6 +83,12 @@ describe('ORM entity CRUD', () => {
const tag2 = client.tags.get(tag.id);
expect(tag2).toEqual(tag);
const user = client.users.create({
name: 'user1',
});
const user2 = client.users.get(user.id);
expect(user2).toEqual(user);
});
test('should be able to update entity', t => {
@ -89,7 +111,7 @@ describe('ORM entity CRUD', () => {
});
// old tag should not be updated
expect(tag.name).not.toBe(tag2.name);
expect(tag.name).not.toBe(tag2!.name);
});
test('should be able to delete entity', t => {
@ -149,6 +171,7 @@ describe('ORM entity CRUD', () => {
const { client } = t;
let tag: Entity<(typeof TEST_SCHEMA)['tags']> | null = null;
const subscription1 = client.tags.get$('test').subscribe(data => {
tag = data;
});
@ -210,15 +233,73 @@ describe('ORM entity CRUD', () => {
subscription.unsubscribe();
});
test('can not use reserved keyword as field name', () => {
const schema = {
tags: {
$$KEY: f.string().primaryKey().default(nanoid),
},
};
test('should be able to subscribe to filtered entity changes', t => {
const { client } = t;
expect(() => createORMClient(schema, YjsDBAdapter, docProvider)).toThrow(
"[Table(tags)]: Field '$$KEY' is reserved keyword and can't be used"
let entities: any[] = [];
const subscription = client.tags.find$({ name: 'test' }).subscribe(data => {
entities = data;
});
const tag1 = client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
expect(entities).toStrictEqual([tag1]);
const tag2 = client.tags.create({
id: '2',
name: 'test',
color: 'blue',
});
expect(entities).toStrictEqual([tag1, tag2]);
subscription.unsubscribe();
});
test('should be able to subscription to any entity changes', t => {
const { client } = t;
let entities: any[] = [];
const subscription = client.tags.find$({}).subscribe(data => {
entities = data;
});
const tag1 = client.tags.create({
id: '1',
name: 'tag1',
color: 'red',
});
expect(entities).toStrictEqual([tag1]);
const tag2 = client.tags.create({
id: '2',
name: 'tag2',
color: 'blue',
});
expect(entities).toStrictEqual([tag1, tag2]);
subscription.unsubscribe();
});
test('can not use reserved keyword as field name', () => {
expect(
() =>
new YjsDBAdapter(
{
tags: {
$$DELETED: f.string().primaryKey().default(nanoid),
},
},
docProvider
)
).toThrow(
"[Table(tags)]: Field '$$DELETED' is reserved keyword and can't be used"
);
});
});

View File

@ -1,19 +1,36 @@
import { merge } from 'lodash-es';
import { merge, pick } from 'lodash-es';
import { HookAdapter } from '../mixins';
import type { Key, TableAdapter, TableOptions } from '../types';
import type {
DeleteQuery,
FindQuery,
InsertQuery,
ObserveQuery,
Select,
TableAdapter,
TableAdapterOptions,
UpdateQuery,
WhereCondition,
} from '../types';
@HookAdapter()
export class MemoryTableAdapter implements TableAdapter {
data = new Map<Key, any>();
subscriptions = new Map<Key, Array<(data: any) => void>>();
private readonly data = new Map<string, any>();
private keyField = 'key';
private readonly subscriptions = new Set<(key: string, data: any) => void>();
constructor(private readonly tableName: string) {}
setup(_opts: TableOptions) {}
setup(opts: TableAdapterOptions) {
this.keyField = opts.keyField;
}
dispose() {}
create(key: Key, data: any) {
insert(query: InsertQuery) {
const { data, select } = query;
const key = String(data[this.keyField]);
if (this.data.has(key)) {
throw new Error(
`Record with key ${key} already exists in table ${this.tableName}`
@ -22,79 +39,125 @@ export class MemoryTableAdapter implements TableAdapter {
this.data.set(key, data);
this.dispatch(key, data);
this.dispatch('$$KEYS', this.keys());
return data;
return this.value(data, select);
}
get(key: Key) {
return this.data.get(key) || null;
}
find(query: FindQuery) {
const { where, select } = query;
const result = [];
subscribe(key: Key, callback: (data: any) => void): () => void {
const sKey = key.toString();
let subs = this.subscriptions.get(sKey.toString());
if (!subs) {
subs = [];
this.subscriptions.set(sKey, subs);
for (const record of this.iterate(where)) {
result.push(this.value(record, select));
}
subs.push(callback);
callback(this.data.get(key) || null);
return result;
}
observe(query: ObserveQuery): () => void {
const { where, select, callback } = query;
let listeningOnAll = false;
const obKeys = new Set<string>();
const results = [];
if (!where) {
listeningOnAll = true;
} else if ('byKey' in where) {
obKeys.add(where.byKey.toString());
}
for (const record of this.iterate(where)) {
const key = String(record[this.keyField]);
if (!listeningOnAll) {
obKeys.add(key);
}
results.push(this.value(record, select));
}
callback(results);
const ob = (key: string, data: any) => {
if (
listeningOnAll ||
obKeys.has(key) ||
(where && this.match(data, where))
) {
callback(this.find({ where, select }));
return;
}
};
this.subscriptions.add(ob);
return () => {
this.subscriptions.set(
sKey,
subs.filter(s => s !== callback)
);
this.subscriptions.delete(ob);
};
}
keys(): Key[] {
return Array.from(this.data.keys());
}
update(query: UpdateQuery) {
const { where, data, select } = query;
const result = [];
subscribeKeys(callback: (keys: Key[]) => void): () => void {
const sKey = `$$KEYS`;
let subs = this.subscriptions.get(sKey);
if (!subs) {
subs = [];
this.subscriptions.set(sKey, subs);
}
subs.push(callback);
callback(this.keys());
return () => {
this.subscriptions.set(
sKey,
subs.filter(s => s !== callback)
);
};
}
update(key: Key, data: any) {
let record = this.data.get(key);
if (!record) {
throw new Error(
`Record with key ${key} does not exist in table ${this.tableName}`
);
for (let record of this.iterate(where)) {
record = merge({}, record, data);
const key = String(record[this.keyField]);
this.data.set(key, record);
this.dispatch(key, record);
result.push(this.value(this.value(record, select)));
}
record = merge({}, record, data);
this.data.set(key, record);
this.dispatch(key, record);
return result;
}
delete(query: DeleteQuery) {
const { where } = query;
for (const record of this.iterate(where)) {
const key = String(record[this.keyField]);
this.data.delete(key);
this.dispatch(key, null);
}
}
toObject(record: any): Record<string, any> {
return record;
}
delete(key: Key) {
this.data.delete(key);
this.dispatch(key, null);
this.dispatch('$$KEYS', this.keys());
value(data: any, select: Select = '*') {
if (select === 'key') {
return data[this.keyField];
}
if (select === '*') {
return this.toObject(data);
}
return pick(this.toObject(data), select);
}
dispatch(key: Key, data: any) {
this.subscriptions.get(key)?.forEach(callback => callback(data));
private *iterate(where: WhereCondition = []) {
if (Array.isArray(where)) {
for (const value of this.data.values()) {
if (this.match(value, where)) {
yield value;
}
}
} else {
const key = where.byKey;
const record = this.data.get(key.toString());
if (record) {
yield record;
}
}
}
private match(record: any, where: WhereCondition) {
return Array.isArray(where)
? where.every(c => record[c.field] === c.value)
: where.byKey === record[this.keyField];
}
private dispatch(key: string, data: any) {
this.subscriptions.forEach(callback => callback(key, data));
}
}

View File

@ -1,6 +1,5 @@
import type { Key, TableAdapter, TableOptions } from '../types';
declare module '../types' {
import type { TableAdapter, TableAdapterOptions } from '../types';
declare module '../../types' {
interface TableOptions {
hooks?: Hook<unknown>[];
}
@ -15,12 +14,17 @@ export interface TableAdapterWithHook<T = unknown> extends Hook<T> {}
export function HookAdapter(): ClassDecorator {
// @ts-expect-error allow
return (Class: { new (...args: any[]): TableAdapter }) => {
return class TableAdapterImpl
return class TableAdapterExtensions
extends Class
implements TableAdapterWithHook
{
hooks: Hook<unknown>[] = [];
override setup(opts: TableAdapterOptions): void {
super.setup(opts);
this.hooks = opts.hooks ?? [];
}
deserialize(data: unknown) {
if (!this.hooks.length) {
return data;
@ -32,28 +36,8 @@ export function HookAdapter(): ClassDecorator {
);
}
override setup(opts: TableOptions) {
this.hooks = opts.hooks || [];
super.setup(opts);
}
override create(key: Key, data: any) {
return this.deserialize(super.create(key, data));
}
override get(key: Key) {
return this.deserialize(super.get(key));
}
override update(key: Key, data: any) {
return this.deserialize(super.update(key, data));
}
override subscribe(
key: Key,
callback: (data: unknown) => void
): () => void {
return super.subscribe(key, data => callback(this.deserialize(data)));
override toObject(data: any): Record<string, any> {
return this.deserialize(super.toObject(data));
}
};
};

View File

@ -1,23 +1,66 @@
import type { TableSchemaBuilder } from '../schema';
import type { Key, TableOptions } from '../types';
export interface Key {
toString(): string;
export interface TableAdapterOptions extends TableOptions {
keyField: string;
}
export interface TableOptions {
schema: TableSchemaBuilder;
}
type WhereEqCondition = {
field: string;
value: any;
};
export interface TableAdapter<K extends Key = any, T = unknown> {
setup(opts: TableOptions): void;
type WhereByKeyCondition = {
byKey: Key;
};
// currently only support eq condition
// TODO(@forehalo): on the way [gt, gte, lt, lte, in, notIn, like, notLike, isNull, isNotNull, And, Or]
export type WhereCondition = WhereEqCondition[] | WhereByKeyCondition;
export type Select = '*' | 'key' | string[];
export type InsertQuery = {
data: any;
select?: Select;
};
export type DeleteQuery = {
where?: WhereCondition;
};
export type UpdateQuery = {
where?: WhereCondition;
data: any;
select?: Select;
};
export type FindQuery = {
where?: WhereCondition;
select?: Select;
};
export type ObserveQuery = {
where?: WhereCondition;
select?: Select;
callback: (data: any[]) => void;
};
export type Query =
| InsertQuery
| DeleteQuery
| UpdateQuery
| FindQuery
| ObserveQuery;
export interface TableAdapter {
setup(opts: TableAdapterOptions): void;
dispose(): void;
create(key: K, data: Partial<T>): T;
get(key: K): T;
subscribe(key: K, callback: (data: T) => void): () => void;
keys(): K[];
subscribeKeys(callback: (keys: K[]) => void): () => void;
update(key: K, data: Partial<T>): T;
delete(key: K): void;
toObject(record: any): Record<string, any>;
insert(query: InsertQuery): any;
update(query: UpdateQuery): any[];
delete(query: DeleteQuery): void;
find(query: FindQuery): any[];
observe(query: ObserveQuery): () => void;
}
export interface DBAdapter {

View File

@ -1,9 +1,24 @@
import { omit } from 'lodash-es';
import type { Doc, Map as YMap, Transaction, YMapEvent } from 'yjs';
import { pick } from 'lodash-es';
import {
type AbstractType,
type Doc,
Map as YMap,
type Transaction,
} from 'yjs';
import { validators } from '../../validators';
import { HookAdapter } from '../mixins';
import type { Key, TableAdapter, TableOptions } from '../types';
import type {
DeleteQuery,
FindQuery,
InsertQuery,
ObserveQuery,
Select,
TableAdapter,
TableAdapterOptions,
UpdateQuery,
WhereCondition,
} from '../types';
/**
* Yjs Adapter for AFFiNE ORM
@ -22,33 +37,29 @@ import type { Key, TableAdapter, TableOptions } from '../types';
@HookAdapter()
export class YjsTableAdapter implements TableAdapter {
private readonly deleteFlagKey = '$$DELETED';
private readonly keyFlagKey = '$$KEY';
private readonly hiddenFields = [this.deleteFlagKey, this.keyFlagKey];
private keyField: string = 'key';
private fields: string[] = [];
private readonly origin = 'YjsTableAdapter';
keysCache: Set<Key> | null = null;
cacheStaled = true;
constructor(
private readonly tableName: string,
private readonly doc: Doc
) {}
setup(_opts: TableOptions): void {
this.doc.on('update', (_, origin) => {
if (origin !== this.origin) {
this.markCacheStaled();
}
});
setup(opts: TableAdapterOptions): void {
this.keyField = opts.keyField;
this.fields = Object.keys(opts.schema);
}
dispose() {
this.doc.destroy();
}
create(key: Key, data: any) {
insert(query: InsertQuery) {
const { data, select } = query;
validators.validateYjsEntityData(this.tableName, data);
const key = data[this.keyField];
const record = this.doc.getMap(key.toString());
this.doc.transact(() => {
@ -56,139 +67,174 @@ export class YjsTableAdapter implements TableAdapter {
record.set(key, data[key]);
}
this.keyBy(record, key);
record.set(this.deleteFlagKey, false);
record.delete(this.deleteFlagKey);
}, this.origin);
this.markCacheStaled();
return this.value(record);
return this.value(record, select);
}
update(key: Key, data: any) {
update(query: UpdateQuery) {
const { data, select, where } = query;
validators.validateYjsEntityData(this.tableName, data);
const record = this.record(key);
if (this.isDeleted(record)) {
return;
}
const results: any[] = [];
this.doc.transact(() => {
for (const key in data) {
record.set(key, data[key]);
}
}, this.origin);
return this.value(record);
}
get(key: Key) {
const record = this.record(key);
return this.value(record);
}
subscribe(key: Key, callback: (data: any) => void) {
const record: YMap<any> = this.record(key);
// init callback
callback(this.value(record));
const ob = (event: YMapEvent<any>) => {
callback(this.value(event.target));
};
record.observe(ob);
return () => {
record.unobserve(ob);
};
}
keys() {
const keysCache = this.buildKeysCache();
return Array.from(keysCache);
}
subscribeKeys(callback: (keys: Key[]) => void) {
const keysCache = this.buildKeysCache();
// init callback
callback(Array.from(keysCache));
const ob = (tx: Transaction) => {
const keysCache = this.buildKeysCache();
for (const [type] of tx.changed) {
const data = type as unknown as YMap<any>;
const key = this.keyof(data);
if (this.isDeleted(data)) {
keysCache.delete(key);
} else {
keysCache.add(key);
for (const record of this.iterate(where)) {
results.push(this.value(record, select));
for (const key in data) {
this.setField(record, key, data[key]);
}
}
}, this.origin);
callback(Array.from(keysCache));
return results;
}
find(query: FindQuery) {
const { where, select } = query;
const records: any[] = [];
for (const record of this.iterate(where)) {
records.push(this.value(record, select));
}
return records;
}
observe(query: ObserveQuery) {
const { where, select, callback } = query;
let listeningOnAll = false;
const obKeys = new Set<any>();
const results = [];
if (!where) {
listeningOnAll = true;
} else if ('byKey' in where) {
obKeys.add(where.byKey.toString());
}
for (const record of this.iterate(where)) {
if (!listeningOnAll) {
obKeys.add(this.keyof(record));
}
results.push(this.value(record, select));
}
callback(results);
const ob = (tx: Transaction) => {
for (const [ty] of tx.changed) {
const record = ty as unknown as AbstractType<any>;
if (
listeningOnAll ||
obKeys.has(this.keyof(record)) ||
(where && this.match(record, where))
) {
callback(this.find({ where, select }));
return;
}
}
};
this.doc.on('afterTransaction', ob);
return () => {
this.doc.off('afterTransaction', ob);
};
}
delete(key: Key) {
const record = this.record(key);
delete(query: DeleteQuery) {
const { where } = query;
this.doc.transact(() => {
for (const key of record.keys()) {
if (!this.hiddenFields.includes(key)) {
record.delete(key);
for (const record of this.iterate(where)) {
this.deleteTy(record);
}
}, this.origin);
}
toObject(ty: AbstractType<any>): Record<string, any> {
return YMap.prototype.toJSON.call(ty);
}
private recordByKey(key: string): AbstractType<any> | null {
// detect if the record is there otherwise yjs will create an empty Map.
if (this.doc.share.has(key)) {
return this.doc.getMap(key);
}
return null;
}
private *iterate(where: WhereCondition = []) {
// fast pass for key lookup without iterating the whole table
if ('byKey' in where) {
const record = this.recordByKey(where.byKey.toString());
if (record) {
yield record;
}
} else if (Array.isArray(where)) {
for (const map of this.doc.share.values()) {
if (this.match(map, where)) {
yield map;
}
}
record.set(this.deleteFlagKey, true);
}, this.origin);
this.markCacheStaled();
}
}
private isDeleted(record: YMap<any>) {
return record.get(this.deleteFlagKey) === true;
}
private record(key: Key) {
return this.doc.getMap(key.toString());
}
private value(record: YMap<any>) {
if (this.isDeleted(record) || !record.size) {
private value(record: AbstractType<any>, select: Select = '*') {
if (this.isDeleted(record) || this.isEmpty(record)) {
return null;
}
return omit(record.toJSON(), this.hiddenFields);
}
private buildKeysCache() {
if (!this.keysCache || this.cacheStaled) {
this.keysCache = new Set();
for (const key of this.doc.share.keys()) {
const record = this.doc.getMap(key);
if (!this.isDeleted(record)) {
this.keysCache.add(this.keyof(record));
}
}
this.cacheStaled = false;
let selectedFields: string[];
if (select === 'key') {
return this.keyof(record);
} else if (select === '*') {
selectedFields = this.fields;
} else {
selectedFields = select;
}
return this.keysCache;
return pick(this.toObject(record), selectedFields);
}
private markCacheStaled() {
this.cacheStaled = true;
private match(record: AbstractType<any>, where: WhereCondition) {
return (
!this.isDeleted(record) &&
(Array.isArray(where)
? where.every(c => this.field(record, c.field) === c.value)
: where.byKey === this.keyof(record))
);
}
private keyof(record: YMap<any>) {
return record.get(this.keyFlagKey);
private isDeleted(record: AbstractType<any>) {
return (
this.field(record, this.deleteFlagKey) === true || this.isEmpty(record)
);
}
private keyBy(record: YMap<any>, key: Key) {
record.set(this.keyFlagKey, key);
private keyof(record: AbstractType<any>) {
return this.field(record, this.keyField);
}
private field(ty: AbstractType<any>, field: string) {
return YMap.prototype.get.call(ty, field);
}
private setField(ty: AbstractType<any>, field: string, value: any) {
YMap.prototype.set.call(ty, field, value);
}
private isEmpty(ty: AbstractType<any>) {
return ty._map.size === 0;
}
private deleteTy(ty: AbstractType<any>) {
this.fields.forEach(field => {
if (field !== this.keyField) {
YMap.prototype.delete.call(ty, field);
}
});
YMap.prototype.set.call(ty, this.deleteFlagKey, true);
}
}

View File

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

View File

@ -1,13 +1,14 @@
import { isUndefined, omitBy } from 'lodash-es';
import { Observable, shareReplay } from 'rxjs';
import type { DBAdapter, Key, TableAdapter, TableOptions } from './adapters';
import type { DBAdapter, TableAdapter } from './adapters';
import type {
DBSchemaBuilder,
FieldSchemaBuilder,
TableSchema,
TableSchemaBuilder,
} from './schema';
import type { Key, TableOptions } from './types';
import { validators } from './validators';
type Pretty<T> = T extends any
@ -74,10 +75,16 @@ export type UpdateEntityInput<T extends TableSchemaBuilder> = Pretty<{
: never;
}>;
export type FindEntityInput<T extends TableSchemaBuilder> = Pretty<{
[key in keyof T]?: T[key] extends FieldSchemaBuilder<infer Type>
? Type
: never;
}>;
export class Table<T extends TableSchemaBuilder> {
readonly schema: TableSchema;
readonly keyField: string = '';
private readonly adapter: TableAdapter<PrimaryKeyFieldType<T>, Entity<T>>;
private readonly adapter: TableAdapter;
private readonly subscribedKeys: Map<Key, Observable<any>> = new Map();
@ -87,7 +94,6 @@ export class Table<T extends TableSchemaBuilder> {
private readonly opts: TableOptions
) {
this.adapter = db.table(name) as any;
this.adapter.setup(opts);
this.schema = Object.entries(this.opts.schema).reduce(
(acc, [fieldName, fieldBuilder]) => {
acc[fieldName] = fieldBuilder.schema;
@ -99,6 +105,7 @@ export class Table<T extends TableSchemaBuilder> {
},
{} as TableSchema
);
this.adapter.setup({ ...opts, keyField: this.keyField });
}
create(input: CreateEntityInput<T>): Entity<T> {
@ -123,16 +130,35 @@ export class Table<T extends TableSchemaBuilder> {
validators.validateCreateEntityData(this, data);
return this.adapter.create(data[this.keyField], data);
return this.adapter.insert({
data: data,
});
}
update(key: PrimaryKeyFieldType<T>, input: UpdateEntityInput<T>): Entity<T> {
update(
key: PrimaryKeyFieldType<T>,
input: UpdateEntityInput<T>
): Entity<T> | null {
validators.validateUpdateEntityData(this, input);
return this.adapter.update(key, omitBy(input, isUndefined) as any);
const [record] = this.adapter.update({
where: {
byKey: key,
},
data: input,
});
return record || null;
}
get(key: PrimaryKeyFieldType<T>): Entity<T> {
return this.adapter.get(key);
get(key: PrimaryKeyFieldType<T>): Entity<T> | null {
const [record] = this.adapter.find({
where: {
byKey: key,
},
});
return record || null;
}
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T>> {
@ -140,8 +166,13 @@ export class Table<T extends TableSchemaBuilder> {
if (!ob$) {
ob$ = new Observable<Entity<T>>(subscriber => {
const unsubscribe = this.adapter.subscribe(key, data => {
subscriber.next(data);
const unsubscribe = this.adapter.observe({
where: {
byKey: key,
},
callback: ([data]) => {
subscriber.next(data || null);
},
});
return () => {
@ -161,8 +192,35 @@ export class Table<T extends TableSchemaBuilder> {
return ob$;
}
find(where: FindEntityInput<T>): Entity<T>[] {
return this.adapter.find({
where: Object.entries(where).map(([field, value]) => ({
field,
value,
})),
});
}
find$(where: FindEntityInput<T>): Observable<Entity<T>[]> {
return new Observable<Entity<T>[]>(subscriber => {
const unsubscribe = this.adapter.observe({
where: Object.entries(where).map(([field, value]) => ({
field,
value,
})),
callback: data => {
subscriber.next(data);
},
});
return unsubscribe;
});
}
keys(): PrimaryKeyFieldType<T>[] {
return this.adapter.keys();
return this.adapter.find({
select: 'key',
});
}
keys$(): Observable<PrimaryKeyFieldType<T>[]> {
@ -170,8 +228,11 @@ export class Table<T extends TableSchemaBuilder> {
if (!ob$) {
ob$ = new Observable<PrimaryKeyFieldType<T>[]>(subscriber => {
const unsubscribe = this.adapter.subscribeKeys(keys => {
subscriber.next(keys);
const unsubscribe = this.adapter.observe({
select: 'key',
callback: (keys: PrimaryKeyFieldType<T>[]) => {
subscriber.next(keys);
},
});
return () => {
@ -192,7 +253,11 @@ export class Table<T extends TableSchemaBuilder> {
}
delete(key: PrimaryKeyFieldType<T>) {
return this.adapter.delete(key);
this.adapter.delete({
where: {
byKey: key,
},
});
}
}

View File

@ -0,0 +1,9 @@
import type { TableSchemaBuilder } from './schema';
export interface Key {
toString(): string;
}
export interface TableOptions {
schema: TableSchemaBuilder;
}

View File

@ -1,6 +1,6 @@
import type { TableSchemaValidator } from './types';
const PRESERVED_FIELDS = ['$$KEY', '$$DELETED'];
const PRESERVED_FIELDS = ['$$DELETED'];
interface DataValidator {
validate(tableName: string, data: any): void;