feat(infra): better orm (#7502)

This commit is contained in:
EYHN
2024-07-18 10:14:12 +00:00
parent 133888d760
commit 9fe77baf05
4 changed files with 181 additions and 51 deletions
@@ -26,6 +26,7 @@ const TEST_SCHEMA = {
users: { users: {
id: f.number().primaryKey().default(incremental()), id: f.number().primaryKey().default(incremental()),
name: f.string(), name: f.string(),
email: f.string().optional(),
}, },
} satisfies DBSchemaBuilder; } satisfies DBSchemaBuilder;
@@ -211,9 +212,11 @@ describe('ORM entity CRUD', () => {
test('should be able to subscribe to entity key list', t => { test('should be able to subscribe to entity key list', t => {
const { client } = t; const { client } = t;
let callbackCount = 0;
let keys: string[] = []; let keys: string[] = [];
const subscription = client.tags.keys$().subscribe(data => { const subscription = client.tags.keys$().subscribe(data => {
keys = data; keys = data;
callbackCount++;
}); });
client.tags.create({ client.tags.create({
@@ -229,6 +232,7 @@ describe('ORM entity CRUD', () => {
client.tags.delete('test'); client.tags.delete('test');
expect(keys).toStrictEqual([]); expect(keys).toStrictEqual([]);
expect(callbackCount).toStrictEqual(3); // init, create, delete
subscription.unsubscribe(); subscription.unsubscribe();
}); });
@@ -236,9 +240,11 @@ describe('ORM entity CRUD', () => {
test('should be able to subscribe to filtered entity changes', t => { test('should be able to subscribe to filtered entity changes', t => {
const { client } = t; const { client } = t;
let callbackCount = 0;
let entities: any[] = []; let entities: any[] = [];
const subscription = client.tags.find$({ name: 'test' }).subscribe(data => { const subscription = client.tags.find$({ name: 'test' }).subscribe(data => {
entities = data; entities = data;
callbackCount++;
}); });
const tag1 = client.tags.create({ const tag1 = client.tags.create({
@@ -257,6 +263,24 @@ describe('ORM entity CRUD', () => {
expect(entities).toStrictEqual([tag1, tag2]); expect(entities).toStrictEqual([tag1, tag2]);
client.tags.create({
id: '3',
name: 'not-test',
color: 'yellow',
});
expect(entities).toStrictEqual([tag1, tag2]);
expect(callbackCount).toStrictEqual(3);
client.tags.update('1', { color: 'green' });
expect(entities).toStrictEqual([{ ...tag1, color: 'green' }, tag2]);
client.tags.delete('1');
expect(entities).toStrictEqual([tag2]);
client.tags.delete('2');
expect(entities).toStrictEqual([]);
subscription.unsubscribe(); subscription.unsubscribe();
}); });
@@ -264,7 +288,7 @@ describe('ORM entity CRUD', () => {
const { client } = t; const { client } = t;
let entities: any[] = []; let entities: any[] = [];
const subscription = client.tags.find$({}).subscribe(data => { const subscription = client.tags.find$().subscribe(data => {
entities = data; entities = data;
}); });
@@ -302,4 +326,82 @@ describe('ORM entity CRUD', () => {
"[Table(tags)]: Field '$$DELETED' is reserved keyword and can't be used" "[Table(tags)]: Field '$$DELETED' is reserved keyword and can't be used"
); );
}); });
test('should be able to validate entity data', t => {
const { client } = t;
expect(() => {
client.users.create({
// @ts-expect-error
name: null,
});
}).toThrowError("Field 'name' is required but not set.");
expect(() => {
// @ts-expect-error
client.users.create({});
}).toThrowError("Field 'name' is required but not set.");
expect(() => {
client.users.update(1, {
// @ts-expect-error
name: null,
});
}).toThrowError("Field 'name' is required but not set.");
});
test('should be able to set optional field to null', t => {
const { client } = t;
{
const user = client.users.create({
name: 'test',
});
expect(user.email).toBe(null);
}
{
const user = client.users.create({
name: 'test',
email: null,
});
expect(user.email).toBe(null);
}
{
const user = client.users.create({
name: 'test',
email: 'test@example.com',
});
client.users.update(user.id, {
email: null,
});
expect(client.users.get(user.id)!.email).toBe(null);
}
});
test('should be able to find entity by optional field', t => {
const { client } = t;
const user = client.users.create({
name: 'test',
email: null,
});
{
const found = client.users.find({ email: null });
expect(found).toEqual([user]);
}
{
const found = client.users.find({ email: undefined });
expect(found).toEqual([]);
}
});
}); });
@@ -104,36 +104,47 @@ export class YjsTableAdapter implements TableAdapter {
const { where, select, callback } = query; const { where, select, callback } = query;
let listeningOnAll = false; let listeningOnAll = false;
const obKeys = new Set<any>(); const results = new Map<string, any>();
const results = [];
if (!where) { if (!where) {
listeningOnAll = true; listeningOnAll = true;
} else if ('byKey' in where) {
obKeys.add(where.byKey.toString());
} }
for (const record of this.iterate(where)) { for (const record of this.iterate(where)) {
if (!listeningOnAll) { results.set(this.keyof(record), this.value(record, select));
obKeys.add(this.keyof(record));
}
results.push(this.value(record, select));
} }
callback(results); callback(Array.from(results.values()));
const ob = (tx: Transaction) => { const ob = (tx: Transaction) => {
let hasChanged = false;
for (const [ty] of tx.changed) { for (const [ty] of tx.changed) {
const record = ty as unknown as AbstractType<any>; const record = ty;
if ( const key = this.keyof(record);
listeningOnAll || const isMatch =
obKeys.has(this.keyof(record)) || (listeningOnAll || (where && this.match(record, where))) &&
(where && this.match(record, where)) !this.isDeleted(record);
) { const prevMatch = results.get(key);
callback(this.find({ where, select })); const isPrevMatched = results.has(key);
return;
if (isMatch && isPrevMatched) {
const newValue = this.value(record, select);
if (prevMatch !== newValue) {
results.set(key, newValue);
hasChanged = true;
}
} else if (isMatch && !isPrevMatched) {
results.set(this.keyof(record), this.value(record, select));
hasChanged = true;
} else if (!isMatch && isPrevMatched) {
results.delete(key);
hasChanged = true;
} }
} }
if (hasChanged) {
callback(Array.from(results.values()));
}
}; };
this.doc.on('afterTransaction', ob); this.doc.on('afterTransaction', ob);
@@ -165,9 +176,16 @@ export class YjsTableAdapter implements TableAdapter {
return null; return null;
} }
private *iterate(where: WhereCondition = []) { private *iterate(where?: WhereCondition) {
if (!where) {
for (const map of this.doc.share.values()) {
if (!this.isDeleted(map)) {
yield map;
}
}
}
// fast pass for key lookup without iterating the whole table // fast pass for key lookup without iterating the whole table
if ('byKey' in where) { else if ('byKey' in where) {
const record = this.recordByKey(where.byKey.toString()); const record = this.recordByKey(where.byKey.toString());
if (record) { if (record) {
yield record; yield record;
@@ -202,7 +220,9 @@ export class YjsTableAdapter implements TableAdapter {
return ( return (
!this.isDeleted(record) && !this.isDeleted(record) &&
(Array.isArray(where) (Array.isArray(where)
? where.every(c => this.field(record, c.field) === c.value) ? where.length === 0
? false
: where.every(c => this.field(record, c.field) === c.value)
: where.byKey === this.keyof(record)) : where.byKey === this.keyof(record))
); );
} }
+24 -18
View File
@@ -30,7 +30,9 @@ type OptionalFields<T extends TableSchemaBuilder> = {
? Optional extends true ? Optional extends true
? K ? K
: never : never
: never]?: T[K] extends FieldSchemaBuilder<infer Type> ? Type : never; : never]?: T[K] extends FieldSchemaBuilder<infer Type>
? Type | null
: never;
}; };
type PrimaryKeyField<T extends TableSchemaBuilder> = { type PrimaryKeyField<T extends TableSchemaBuilder> = {
@@ -68,17 +70,13 @@ export type Entity<T extends TableSchemaBuilder> = Pretty<
>; >;
export type UpdateEntityInput<T extends TableSchemaBuilder> = Pretty<{ export type UpdateEntityInput<T extends TableSchemaBuilder> = Pretty<{
[key in NonPrimaryKeyFields<T>]?: T[key] extends FieldSchemaBuilder< [key in NonPrimaryKeyFields<T>]?: key extends keyof Entity<T>
infer Type ? Entity<T>[key]
>
? Type
: never; : never;
}>; }>;
export type FindEntityInput<T extends TableSchemaBuilder> = Pretty<{ export type FindEntityInput<T extends TableSchemaBuilder> = Pretty<{
[key in keyof T]?: T[key] extends FieldSchemaBuilder<infer Type> [key in keyof T]?: key extends keyof Entity<T> ? Entity<T>[key] : never;
? Type
: never;
}>; }>;
export class Table<T extends TableSchemaBuilder> { export class Table<T extends TableSchemaBuilder> {
@@ -192,22 +190,30 @@ export class Table<T extends TableSchemaBuilder> {
return ob$; return ob$;
} }
find(where: FindEntityInput<T>): Entity<T>[] { find(where?: FindEntityInput<T>): Entity<T>[] {
return this.adapter.find({ return this.adapter.find({
where: Object.entries(where).map(([field, value]) => ({ where: !where
field, ? undefined
value, : Object.entries(where)
})), .map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
}); });
} }
find$(where: FindEntityInput<T>): Observable<Entity<T>[]> { find$(where?: FindEntityInput<T>): Observable<Entity<T>[]> {
return new Observable<Entity<T>[]>(subscriber => { return new Observable<Entity<T>[]>(subscriber => {
const unsubscribe = this.adapter.observe({ const unsubscribe = this.adapter.observe({
where: Object.entries(where).map(([field, value]) => ({ where: !where
field, ? undefined
value, : Object.entries(where)
})), .map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
callback: data => { callback: data => {
subscriber.next(data); subscriber.next(data);
}, },
@@ -65,14 +65,13 @@ export const dataValidators = {
continue; continue;
} }
if ( if (val === null) {
val === null && if (!field.optional) {
(!field.optional || throw new Error(
field.optional) /* say 'null' can be stored as 'json' */ `[Table(${table.name})]: Field '${key}' is required but not set.`
) { );
throw new Error( }
`[Table(${table.name})]: Field '${key}' is required but set as null.` continue;
);
} }
const typeGet = inputType(val); const typeGet = inputType(val);
@@ -97,10 +96,13 @@ export const dataValidators = {
const val = data[key]; const val = data[key];
if ((val === undefined || val === null) && !field.optional) { if (val === undefined || val === null) {
throw new Error( if (!field.optional) {
`[Table(${table.name})]: Field '${key}' is required but not set.` throw new Error(
); `[Table(${table.name})]: Field '${key}' is required but not set.`
);
}
continue;
} }
const typeGet = inputType(val); const typeGet = inputType(val);