mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
public commit
This commit is contained in:
231
app/src/data/relations/EntityRelation.ts
Normal file
231
app/src/data/relations/EntityRelation.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { type Static, Type, parse } from "core/utils";
|
||||
import type { SelectQueryBuilder } from "kysely";
|
||||
import type { Entity, EntityData, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelationAnchor,
|
||||
type MutationInstructionResponse,
|
||||
RelationHelper
|
||||
} from "../relations";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import type { RelationType } from "./relation-types";
|
||||
|
||||
export type KyselyJsonFrom = any;
|
||||
export type KyselyQueryBuilder = SelectQueryBuilder<any, any, any>;
|
||||
|
||||
/*export type RelationConfig = {
|
||||
mappedBy?: string;
|
||||
inversedBy?: string;
|
||||
sourceCardinality?: number;
|
||||
connectionTable?: string;
|
||||
connectionTableMappedName?: string;
|
||||
required?: boolean;
|
||||
};*/
|
||||
|
||||
export type BaseRelationConfig = Static<typeof EntityRelation.schema>;
|
||||
|
||||
// @todo: add generic type for relation config
|
||||
export abstract class EntityRelation<
|
||||
Schema extends typeof EntityRelation.schema = typeof EntityRelation.schema
|
||||
> {
|
||||
config: Static<Schema>;
|
||||
|
||||
source: EntityRelationAnchor;
|
||||
target: EntityRelationAnchor;
|
||||
|
||||
// @todo: add unit tests
|
||||
// allowed directions, used in RelationAccessor for visibility
|
||||
directions: ("source" | "target")[] = ["source", "target"];
|
||||
|
||||
static schema = Type.Object({
|
||||
mappedBy: Type.Optional(Type.String()),
|
||||
inversedBy: Type.Optional(Type.String()),
|
||||
required: Type.Optional(Type.Boolean())
|
||||
});
|
||||
|
||||
// don't make protected, App requires it to instantiatable
|
||||
constructor(
|
||||
source: EntityRelationAnchor,
|
||||
target: EntityRelationAnchor,
|
||||
config: Partial<Static<Schema>> = {}
|
||||
) {
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
|
||||
const schema = (this.constructor as typeof EntityRelation).schema;
|
||||
// @ts-ignore for now
|
||||
this.config = parse(schema, config);
|
||||
}
|
||||
|
||||
abstract initialize(em: EntityManager<any>): void;
|
||||
|
||||
/**
|
||||
* Build the "with" part of the query.
|
||||
*
|
||||
* @param entity requesting entity, so target needs to be added
|
||||
* @param qb
|
||||
* @param jsonFrom
|
||||
*/
|
||||
abstract buildWith(
|
||||
entity: Entity,
|
||||
qb: KyselyQueryBuilder,
|
||||
jsonFrom: KyselyJsonFrom,
|
||||
reference: string
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
abstract buildJoin(
|
||||
entity: Entity,
|
||||
qb: KyselyQueryBuilder,
|
||||
reference: string
|
||||
): KyselyQueryBuilder;
|
||||
|
||||
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
helper(entity_name: string): RelationHelper {
|
||||
return new RelationHelper(this, entity_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the other side of the relation quickly
|
||||
* @param entity
|
||||
*/
|
||||
other(entity: Entity | string): EntityRelationAnchor {
|
||||
const entity_name = typeof entity === "string" ? entity : entity.name;
|
||||
|
||||
// special case for self referencing, check which side is not cardinality 1
|
||||
if (this.source.entity.name === this.target.entity.name) {
|
||||
return this.source.cardinality === 1 ? this.target : this.source;
|
||||
}
|
||||
|
||||
if (this.source.entity.name === entity_name) {
|
||||
return this.target;
|
||||
} else if (this.target.entity.name === entity_name) {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Entity "${entity_name}" is not part of the relation ` +
|
||||
`"${this.source.entity.name} <-> ${this.target.entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
ref(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.source : this.target;
|
||||
}
|
||||
|
||||
otherRef(reference: string): EntityRelationAnchor {
|
||||
return this.source.reference === reference ? this.target : this.source;
|
||||
}
|
||||
|
||||
// @todo: add unit tests
|
||||
visibleFrom(from: "source" | "target"): boolean {
|
||||
return this.directions.includes(from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the relation. "entity" represents where the payload belongs to.
|
||||
* E.g. if entity is "categories", then value is the result of categories
|
||||
*
|
||||
* IMPORTANT: This method is called from EM, high potential of recursion!
|
||||
*
|
||||
* @param entity
|
||||
* @param value
|
||||
* @param em
|
||||
*/
|
||||
hydrate(entity: Entity | string, value: EntityData[], em: EntityManager<any>) {
|
||||
const entity_name = typeof entity === "string" ? entity : entity.name;
|
||||
const anchor = this.ref(entity_name);
|
||||
const hydrated = em.hydrate(anchor.entity.name, value);
|
||||
|
||||
if (anchor.cardinality === 1) {
|
||||
if (Array.isArray(hydrated) && hydrated.length > 1) {
|
||||
throw new Error(
|
||||
`Failed to hydrate "${anchor.entity.name}" ` +
|
||||
`with value: ${JSON.stringify(value)} (cardinality: 1)`
|
||||
);
|
||||
}
|
||||
|
||||
return hydrated[0];
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
throw new Error(
|
||||
`Failed to hydrate "${anchor.entity.name}" ` +
|
||||
`with value: ${JSON.stringify(value)} (cardinality: -)`
|
||||
);
|
||||
}
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the relation is listable for the given entity
|
||||
* If the given entity is the one with the local reference, then it's not listable
|
||||
* Only if there are multiple, which is generally the other side (except for 1:1)
|
||||
* @param entity
|
||||
*/
|
||||
isListableFor(entity: Entity): boolean {
|
||||
//console.log("isListableFor", entity.name, this.source.entity.name, this.target.entity.name);
|
||||
return this.target.entity.name === entity.name;
|
||||
}
|
||||
|
||||
abstract type(): RelationType;
|
||||
|
||||
get required(): boolean {
|
||||
return !!this.config.required;
|
||||
}
|
||||
|
||||
async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$set is not allowed");
|
||||
}
|
||||
|
||||
async $create(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$create is not allowed");
|
||||
}
|
||||
|
||||
async $attach(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$attach is not allowed");
|
||||
}
|
||||
|
||||
async $detach(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
throw new Error("$detach is not allowed");
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
const parts = [
|
||||
this.type().replace(":", ""),
|
||||
this.source.entity.name,
|
||||
this.target.entity.name,
|
||||
this.config.mappedBy,
|
||||
this.config.inversedBy
|
||||
].filter(Boolean);
|
||||
return parts.join("_");
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: this.type(),
|
||||
source: this.source.entity.name,
|
||||
target: this.target.entity.name,
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
25
app/src/data/relations/EntityRelationAnchor.ts
Normal file
25
app/src/data/relations/EntityRelationAnchor.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Entity } from "../entities";
|
||||
|
||||
export class EntityRelationAnchor {
|
||||
entity: Entity;
|
||||
cardinality?: number;
|
||||
|
||||
/**
|
||||
* The name that the other entity will use to reference this entity
|
||||
*/
|
||||
reference: string;
|
||||
|
||||
constructor(entity: Entity, name: string, cardinality?: number) {
|
||||
this.entity = entity;
|
||||
this.cardinality = cardinality;
|
||||
this.reference = name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
entity: this.entity.name,
|
||||
cardinality: this.cardinality,
|
||||
name: this.reference,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
app/src/data/relations/ManyToManyRelation.ts
Normal file
189
app/src/data/relations/ManyToManyRelation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField, VirtualField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField } from "./RelationField";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export type ManyToManyRelationConfig = Static<typeof ManyToManyRelation.schema>;
|
||||
|
||||
export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation.schema> {
|
||||
connectionEntity: Entity;
|
||||
additionalFields: Field[] = [];
|
||||
connectionTableMappedName: string;
|
||||
private em?: EntityManager<any>;
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
connectionTable: Type.Optional(Type.String()),
|
||||
connectionTableMappedName: Type.Optional(Type.String())
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
config?: ManyToManyRelationConfig,
|
||||
additionalFields?: Field[]
|
||||
) {
|
||||
const connectionTable =
|
||||
config?.connectionTable || ManyToManyRelation.defaultConnectionTable(source, target);
|
||||
|
||||
const sourceAnchor = new EntityRelationAnchor(source, source.name);
|
||||
const targetAnchor = new EntityRelationAnchor(target, target.name);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.connectionEntity = new Entity(connectionTable, additionalFields, undefined, "generated");
|
||||
|
||||
this.connectionTableMappedName = config?.connectionTableMappedName || connectionTable;
|
||||
this.additionalFields = additionalFields || [];
|
||||
//this.connectionTable = connectionTable;
|
||||
}
|
||||
|
||||
static defaultConnectionTable(source: Entity, target: Entity) {
|
||||
return `${source.name}_${target.name}`;
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.ManyToMany;
|
||||
}
|
||||
|
||||
/**
|
||||
* Many to many is always listable in both directions
|
||||
*/
|
||||
override isListableFor(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getField(entity: Entity): RelationField {
|
||||
const conn = this.connectionEntity;
|
||||
const selfField = conn.fields.find(
|
||||
(f) => f instanceof RelationField && f.target() === entity.name
|
||||
)!;
|
||||
|
||||
if (!selfField || !(selfField instanceof RelationField)) {
|
||||
throw new Error(
|
||||
`Connection entity "${conn.name}" does not have a relation to "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
return selfField;
|
||||
}
|
||||
|
||||
private getQueryInfo(entity: Entity) {
|
||||
const other = this.other(entity);
|
||||
const conn = this.connectionEntity;
|
||||
const entityField = this.getField(entity);
|
||||
const otherField = this.getField(other.entity);
|
||||
const join = [
|
||||
conn.name,
|
||||
`${other.entity.name}.${other.entity.getPrimaryField().name}`,
|
||||
`${conn.name}.${otherField.name}`
|
||||
] as const;
|
||||
|
||||
const entityRef = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
const otherRef = `${conn.name}.${entityField.name}`;
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
other,
|
||||
join,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
const conn = this.connectionEntity;
|
||||
|
||||
return {
|
||||
where: {
|
||||
[`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id
|
||||
},
|
||||
join: [this.target.reference]
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder) {
|
||||
const { other, join, entityRef, otherRef, groupBy } = this.getQueryInfo(entity);
|
||||
|
||||
return qb
|
||||
.innerJoin(other.entity.name, entityRef, otherRef)
|
||||
.innerJoin(...join)
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
|
||||
if (!this.em) {
|
||||
throw new Error("EntityManager not set, can't build");
|
||||
}
|
||||
const jsonBuildObject = this.em.connection.fn.jsonBuildObject;
|
||||
if (!jsonBuildObject) {
|
||||
throw new Error("Connection does not support jsonBuildObject");
|
||||
}
|
||||
|
||||
const limit = 5;
|
||||
const { other, join, entityRef, otherRef } = this.getQueryInfo(entity);
|
||||
const additionalFields = this.connectionEntity.fields.filter(
|
||||
(f) => !(f instanceof RelationField || f instanceof PrimaryField)
|
||||
);
|
||||
|
||||
return qb.select((eb) => {
|
||||
const select: any[] = other.entity.getSelect(other.entity.name);
|
||||
// @todo: also add to find by references
|
||||
if (additionalFields.length > 0) {
|
||||
const conn = this.connectionEntity.name;
|
||||
select.push(
|
||||
jsonBuildObject(
|
||||
Object.fromEntries(
|
||||
additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
|
||||
)
|
||||
).as(this.connectionTableMappedName)
|
||||
);
|
||||
}
|
||||
|
||||
return jsonFrom(
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.select(select)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.innerJoin(...join)
|
||||
.limit(limit)
|
||||
).as(other.reference);
|
||||
});
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
this.em = em;
|
||||
|
||||
//this.connectionEntity.addField(new RelationField(this.source.entity));
|
||||
//this.connectionEntity.addField(new RelationField(this.target.entity));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.source));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.target));
|
||||
|
||||
// @todo: check this
|
||||
for (const field of this.additionalFields) {
|
||||
this.source.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
this.target.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
}
|
||||
|
||||
em.addEntity(this.connectionEntity);
|
||||
}
|
||||
|
||||
override getName(): string {
|
||||
return [
|
||||
super.getName(),
|
||||
[this.connectionEntity.name, this.connectionTableMappedName].filter(Boolean)
|
||||
].join("_");
|
||||
}
|
||||
}
|
||||
228
app/src/data/relations/ManyToOneRelation.ts
Normal file
228
app/src/data/relations/ManyToOneRelation.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Source entity receives the mapping field
|
||||
*
|
||||
* Many-to-one (many) [sources] has (one) [target]
|
||||
* Example: [posts] has (one) [user]
|
||||
* posts gets a users_id field
|
||||
*/
|
||||
|
||||
export type ManyToOneRelationConfig = Static<typeof ManyToOneRelation.schema>;
|
||||
|
||||
export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.schema> {
|
||||
private fieldConfig?: RelationFieldBaseConfig;
|
||||
static DEFAULTS = {
|
||||
with_limit: 5
|
||||
};
|
||||
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
sourceCardinality: Type.Optional(Type.Number()),
|
||||
with_limit: Type.Optional(
|
||||
Type.Number({ default: ManyToOneRelation.DEFAULTS.with_limit })
|
||||
),
|
||||
fieldConfig: Type.Optional(
|
||||
Type.Object({
|
||||
label: Type.String()
|
||||
})
|
||||
)
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(
|
||||
source: Entity,
|
||||
target: Entity,
|
||||
config: Partial<Static<typeof ManyToOneRelation.schema>> = {}
|
||||
) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
const inversedBy = config.inversedBy || source.name;
|
||||
|
||||
// if source can be multiple, allow it. otherwise unlimited
|
||||
const sourceCardinality =
|
||||
typeof config.sourceCardinality === "number" && config.sourceCardinality > 0
|
||||
? config.sourceCardinality
|
||||
: undefined;
|
||||
const sourceAnchor = new EntityRelationAnchor(source, inversedBy, sourceCardinality);
|
||||
const targetAnchor = new EntityRelationAnchor(target, mappedBy, 1);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.fieldConfig = config.fieldConfig ?? {};
|
||||
// set relation required or not
|
||||
//this.required = !!config.required;
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.ManyToOne;
|
||||
}
|
||||
|
||||
override initialize(em: EntityManager<any>) {
|
||||
const defaultLabel = snakeToPascalWithSpaces(this.target.reference);
|
||||
|
||||
// add required mapping field on source
|
||||
const field = RelationField.create(this, this.target, {
|
||||
label: defaultLabel,
|
||||
...this.fieldConfig
|
||||
});
|
||||
|
||||
if (!this.source.entity.field(field.name)) {
|
||||
this.source.entity.addField(
|
||||
RelationField.create(this, this.target, {
|
||||
label: defaultLabel,
|
||||
...this.fieldConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the RelationField
|
||||
*/
|
||||
getField(): RelationField {
|
||||
const id = this.target.entity.getPrimaryField().name;
|
||||
const field = this.source.entity.getField(`${this.target.reference}_${id}`);
|
||||
|
||||
if (!(field instanceof RelationField)) {
|
||||
throw new Error(
|
||||
`Field "${this.target.reference}_${id}" not found on entity "${this.source.entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
private queryInfo(entity: Entity, reference: string) {
|
||||
const side = this.source.reference === reference ? "source" : "target";
|
||||
const self = this[side];
|
||||
const other = this[side === "source" ? "target" : "source"];
|
||||
let relationRef: string;
|
||||
let entityRef: string;
|
||||
let otherRef: string;
|
||||
if (side === "source") {
|
||||
relationRef = this.source.reference;
|
||||
entityRef = `${relationRef}.${this.getField().name}`;
|
||||
otherRef = `${entity.name}.${self.entity.getPrimaryField().name}`;
|
||||
} else {
|
||||
relationRef = this.target.reference;
|
||||
entityRef = `${relationRef}.${entity.getPrimaryField().name}`;
|
||||
otherRef = `${entity.name}.${this.getField().name}`;
|
||||
}
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
//console.log("queryInfo", entity.name, { reference, side, relationRef, entityRef, otherRef });
|
||||
|
||||
return {
|
||||
other,
|
||||
self,
|
||||
relationRef,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
|
||||
const side = this.source.reference === reference ? "source" : "target";
|
||||
const self = this[side];
|
||||
const other = this[side === "source" ? "target" : "source"];
|
||||
const otherRef = `${other.reference}_${other.entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
where: {
|
||||
[otherRef]: id
|
||||
},
|
||||
join: other.entity.name === self.entity.name ? [] : [other.entity.name]
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder, reference: string) {
|
||||
const { self, entityRef, otherRef, groupBy } = this.queryInfo(entity, reference);
|
||||
return qb.innerJoin(self.entity.name, entityRef, otherRef).groupBy(groupBy);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom, reference: string) {
|
||||
const { self, entityRef, otherRef, relationRef } = this.queryInfo(entity, reference);
|
||||
const limit =
|
||||
self.cardinality === 1
|
||||
? 1
|
||||
: this.config.with_limit ?? ManyToOneRelation.DEFAULTS.with_limit;
|
||||
//console.log("buildWith", entity.name, reference, { limit });
|
||||
|
||||
return qb.select((eb) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(`${self.entity.name} as ${relationRef}`)
|
||||
.select(self.entity.getSelect(relationRef))
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(relationRef)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* $set is performed using the reference:
|
||||
* { [reference]: { $set: { id: 1 } } }
|
||||
*
|
||||
* It must resolve from [reference] ("users") to field ("user_id")
|
||||
* -> returns instructions
|
||||
*/
|
||||
override async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: object
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (typeof value !== "object") {
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected object.`);
|
||||
}
|
||||
|
||||
const entity = this.source.entity;
|
||||
const helper = this.helper(entity.name);
|
||||
const info = helper.getMutationInfo();
|
||||
if (!info.$set) {
|
||||
throw new Error(`Cannot perform $set for relation "${key}"`);
|
||||
}
|
||||
|
||||
const local_field = info.local_field;
|
||||
const field = this.getField();
|
||||
// @ts-ignore
|
||||
const primaryReference = value[Object.keys(value)[0]] as PrimaryFieldType;
|
||||
|
||||
if (!local_field || !(field instanceof RelationField)) {
|
||||
throw new Error(`Cannot perform $set for relation "${key}"`);
|
||||
}
|
||||
|
||||
// if "{ $set: { id: null } }" given, and not required, allow it
|
||||
if (primaryReference === null && !field.isRequired()) {
|
||||
return [local_field, null] satisfies MutationInstructionResponse;
|
||||
}
|
||||
|
||||
const query = await em.repository(field.target()).exists({
|
||||
[field.targetField()]: primaryReference as any
|
||||
});
|
||||
|
||||
if (!query.exists) {
|
||||
const idProp = field.targetField();
|
||||
throw new Error(
|
||||
`Cannot connect "${entity.name}.${key}" to ` +
|
||||
`"${field.target()}.${idProp}" = "${primaryReference}": not found.`
|
||||
);
|
||||
}
|
||||
|
||||
return [local_field, primaryReference] satisfies MutationInstructionResponse;
|
||||
}
|
||||
}
|
||||
77
app/src/data/relations/OneToOneRelation.ts
Normal file
77
app/src/data/relations/OneToOneRelation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRelation";
|
||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Both source and target receive a mapping field
|
||||
* @todo: determine if it should be removed
|
||||
*/
|
||||
export type OneToOneRelationConfig = ManyToOneRelationConfig;
|
||||
|
||||
/* export type OneToOneRelationConfig = {
|
||||
mappedBy?: string; // author|users
|
||||
inversedBy?: string; // posts
|
||||
required?: boolean;
|
||||
}; */
|
||||
|
||||
export class OneToOneRelation extends ManyToOneRelation {
|
||||
constructor(source: Entity, target: Entity, config?: OneToOneRelationConfig) {
|
||||
const { mappedBy, inversedBy, required } = config || {};
|
||||
super(source, target, {
|
||||
mappedBy,
|
||||
inversedBy,
|
||||
sourceCardinality: 1,
|
||||
required
|
||||
});
|
||||
}
|
||||
|
||||
override type(): RelationType {
|
||||
return RelationTypes.OneToOne;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-to-one relations are not listable in either direction
|
||||
*/
|
||||
override isListableFor(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// need to override since it inherits manytoone
|
||||
override async $set(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: object
|
||||
): Promise<MutationInstructionResponse> {
|
||||
throw new Error("$set is not allowed");
|
||||
}
|
||||
|
||||
override async $create(
|
||||
em: EntityManager<any>,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (value === null || typeof value !== "object") {
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected object.`);
|
||||
}
|
||||
|
||||
const target = this.other(this.source.entity).entity;
|
||||
const helper = this.helper(this.source.entity.name);
|
||||
const info = helper.getMutationInfo();
|
||||
const primary = info.primary;
|
||||
const local_field = info.local_field;
|
||||
if (!info.$create || !primary || !local_field) {
|
||||
throw new Error(`Cannot perform $create for relation "${key}"`);
|
||||
}
|
||||
|
||||
// create the relational entity
|
||||
try {
|
||||
const { data } = await em.mutator(target).insertOne(value);
|
||||
|
||||
const retrieved_value = data[primary];
|
||||
return [local_field, retrieved_value] satisfies MutationInstructionResponse;
|
||||
} catch (e) {
|
||||
throw new Error(`Error performing $create on "${target.name}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/data/relations/PolymorphicRelation.ts
Normal file
130
app/src/data/relations/PolymorphicRelation.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
|
||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export type PolymorphicRelationConfig = Static<typeof PolymorphicRelation.schema>;
|
||||
|
||||
// @todo: what about cascades?
|
||||
export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelation.schema> {
|
||||
static override schema = Type.Composite(
|
||||
[
|
||||
EntityRelation.schema,
|
||||
Type.Object({
|
||||
targetCardinality: Type.Optional(Type.Number())
|
||||
})
|
||||
],
|
||||
{
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
constructor(source: Entity, target: Entity, config: Partial<PolymorphicRelationConfig> = {}) {
|
||||
const mappedBy = config.mappedBy || target.name;
|
||||
const inversedBy = config.inversedBy || source.name;
|
||||
|
||||
// if target can be multiple, allow it. otherwise unlimited
|
||||
const targetCardinality =
|
||||
typeof config.targetCardinality === "number" && config.targetCardinality > 0
|
||||
? config.targetCardinality
|
||||
: undefined;
|
||||
const sourceAnchor = new EntityRelationAnchor(source, inversedBy, 1);
|
||||
const targetAnchor = new EntityRelationAnchor(target, mappedBy, targetCardinality);
|
||||
super(sourceAnchor, targetAnchor, config);
|
||||
|
||||
this.directions = ["source"];
|
||||
}
|
||||
|
||||
type(): RelationType {
|
||||
return RelationTypes.Polymorphic;
|
||||
}
|
||||
|
||||
private queryInfo(entity: Entity) {
|
||||
const other = this.other(entity);
|
||||
const whereLhs = `${other.entity.name}.${this.getReferenceField().name}`;
|
||||
const reference = `${entity.name}.${this.config.mappedBy}`;
|
||||
|
||||
// this is used for "getReferenceQuery"
|
||||
const reference_other = `${other.entity.name}.${this.config.mappedBy}`;
|
||||
|
||||
const entityRef = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
const otherRef = `${other.entity.name}.${this.getEntityIdField().name}`;
|
||||
|
||||
const groupBy = `${entity.name}.${entity.getPrimaryField().name}`;
|
||||
|
||||
return {
|
||||
other,
|
||||
whereLhs,
|
||||
reference,
|
||||
reference_other,
|
||||
entityRef,
|
||||
otherRef,
|
||||
groupBy
|
||||
};
|
||||
}
|
||||
|
||||
buildJoin(entity: Entity, qb: KyselyQueryBuilder) {
|
||||
const { other, whereLhs, reference, entityRef, otherRef, groupBy } = this.queryInfo(entity);
|
||||
|
||||
return qb
|
||||
.innerJoin(other.entity.name, (join) =>
|
||||
join.onRef(entityRef, "=", otherRef).on(whereLhs, "=", reference)
|
||||
)
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
const info = this.queryInfo(entity);
|
||||
|
||||
return {
|
||||
where: {
|
||||
[this.getReferenceField().name]: info.reference_other,
|
||||
[this.getEntityIdField().name]: id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
|
||||
const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity);
|
||||
const limit = other.cardinality === 1 ? 1 : 5;
|
||||
|
||||
return qb.select((eb) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.select(other.entity.getSelect(other.entity.name))
|
||||
.where(whereLhs, "=", reference)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(other.reference)
|
||||
);
|
||||
}
|
||||
|
||||
override isListableFor(entity: Entity): boolean {
|
||||
// @todo: only make listable if many? check cardinality
|
||||
return this.source.entity.name === entity.name && this.target.cardinality !== 1;
|
||||
}
|
||||
|
||||
getReferenceField(): TextField {
|
||||
return new TextField("reference", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
getEntityIdField(): NumberField {
|
||||
return new NumberField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
const referenceField = this.getReferenceField();
|
||||
const entityIdField = this.getEntityIdField();
|
||||
|
||||
if (!this.target.entity.field(referenceField.name)) {
|
||||
this.target.entity.addField(referenceField);
|
||||
}
|
||||
if (!this.target.entity.field(entityIdField.name)) {
|
||||
this.target.entity.addField(entityIdField);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/src/data/relations/RelationAccessor.ts
Normal file
74
app/src/data/relations/RelationAccessor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Entity } from "../entities";
|
||||
import type { EntityRelation } from "../relations";
|
||||
|
||||
export class RelationAccessor {
|
||||
private readonly _relations: EntityRelation[] = [];
|
||||
|
||||
constructor(relations: EntityRelation[]) {
|
||||
this._relations = relations;
|
||||
}
|
||||
|
||||
get all(): EntityRelation[] {
|
||||
return this._relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the relations of [entity_name]
|
||||
*/
|
||||
relationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return (
|
||||
(relation.visibleFrom("source") && relation.source.entity.name === entity.name) ||
|
||||
(relation.visibleFrom("target") && relation.target.entity.name === entity.name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
sourceRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return relation.source.entity.name === entity.name;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for relations that have [entity] as target
|
||||
* - meaning it returns entities that holds a local reference field
|
||||
*/
|
||||
targetRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this._relations.filter((relation) => {
|
||||
return relation.visibleFrom("target") && relation.target.entity.name === entity.name;
|
||||
});
|
||||
}
|
||||
|
||||
listableRelationsOf(entity: Entity): EntityRelation[] {
|
||||
return this.relationsOf(entity).filter((relation) => relation.isListableFor(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the relations of [entity_name] and
|
||||
* return the one that has [reference] as source or target.
|
||||
*/
|
||||
relationOf(entity: Entity, reference: string): EntityRelation | undefined {
|
||||
return this.relationsOf(entity).find((r) => {
|
||||
return r.source.reference === reference || r.target.reference === reference;
|
||||
});
|
||||
}
|
||||
|
||||
hasRelations(entity: Entity): boolean {
|
||||
return this.relationsOf(entity).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of related entities of [entity_name]
|
||||
*/
|
||||
relatedEntitiesOf(entity: Entity): Entity[] {
|
||||
return this.relationsOf(entity).map((r) => r.other(entity).entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relation names of [entity_name]
|
||||
*/
|
||||
relationReferencesOf(entity): string[] {
|
||||
return this.relationsOf(entity).map((r) => r.other(entity).reference);
|
||||
}
|
||||
}
|
||||
101
app/src/data/relations/RelationField.ts
Normal file
101
app/src/data/relations/RelationField.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import type { EntityManager } from "../entities";
|
||||
import { Field, type SchemaResponse, baseFieldConfigSchema } from "../fields";
|
||||
import type { EntityRelation } from "./EntityRelation";
|
||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||
|
||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
||||
|
||||
export const relationFieldConfigSchema = Type.Composite([
|
||||
baseFieldConfigSchema,
|
||||
Type.Object({
|
||||
reference: Type.String(),
|
||||
target: Type.String(), // @todo: potentially has to be an instance!
|
||||
target_field: Type.Optional(Type.String({ default: "id" })),
|
||||
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" }))
|
||||
})
|
||||
]);
|
||||
/*export const relationFieldConfigSchema = baseFieldConfigSchema.extend({
|
||||
reference: z.string(),
|
||||
target: z.string(),
|
||||
target_field: z.string().catch("id"),
|
||||
});*/
|
||||
|
||||
export type RelationFieldConfig = Static<typeof relationFieldConfigSchema>;
|
||||
export type RelationFieldBaseConfig = { label?: string };
|
||||
|
||||
export class RelationField extends Field<RelationFieldConfig> {
|
||||
override readonly type = "relation";
|
||||
|
||||
protected getSchema() {
|
||||
return relationFieldConfigSchema;
|
||||
}
|
||||
|
||||
/*constructor(name: string, config?: Partial<RelationFieldConfig>) {
|
||||
//relation_name = relation_name || target.name;
|
||||
//const name = [relation_name, target.getPrimaryField().name].join("_");
|
||||
super(name, config);
|
||||
|
||||
//console.log(this.config);
|
||||
//this.relation.target = target;
|
||||
//this.relation.name = relation_name;
|
||||
}*/
|
||||
|
||||
static create(
|
||||
relation: EntityRelation,
|
||||
target: EntityRelationAnchor,
|
||||
config?: RelationFieldBaseConfig
|
||||
) {
|
||||
const name = [
|
||||
target.reference ?? target.entity.name,
|
||||
target.entity.getPrimaryField().name
|
||||
].join("_");
|
||||
//console.log('name', name);
|
||||
return new RelationField(name, {
|
||||
...config,
|
||||
required: relation.required,
|
||||
reference: target.reference,
|
||||
target: target.entity.name,
|
||||
target_field: target.entity.getPrimaryField().name
|
||||
});
|
||||
}
|
||||
|
||||
reference() {
|
||||
return this.config.reference;
|
||||
}
|
||||
|
||||
target() {
|
||||
return this.config.target;
|
||||
}
|
||||
|
||||
targetField(): string {
|
||||
return this.config.target_field!;
|
||||
}
|
||||
|
||||
override schema(): SchemaResponse {
|
||||
return this.useSchemaHelper("integer", (col) => {
|
||||
//col.references('person.id').onDelete('cascade').notNull()
|
||||
// @todo: implement cascading?
|
||||
|
||||
return col
|
||||
.references(`${this.config.target}.${this.config.target_field}`)
|
||||
.onDelete(this.config.on_delete ?? "set null");
|
||||
});
|
||||
}
|
||||
|
||||
override transformRetrieve(value: any): any {
|
||||
return value;
|
||||
}
|
||||
|
||||
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
|
||||
throw new Error("This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
return this.toSchemaWrapIfRequired(
|
||||
Type.Number({
|
||||
$ref: `${this.config?.target}#/properties/${this.config?.target_field}`
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
86
app/src/data/relations/RelationHelper.ts
Normal file
86
app/src/data/relations/RelationHelper.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type EntityRelation,
|
||||
type EntityRelationAnchor,
|
||||
type ManyToOneRelation,
|
||||
type OneToOneRelation,
|
||||
RelationTypes,
|
||||
} from "../relations";
|
||||
|
||||
export const MutationOperations = ["$set", "$create", "$attach", "$detach"] as const;
|
||||
export type MutationOperation = (typeof MutationOperations)[number];
|
||||
|
||||
export class RelationHelper {
|
||||
relation: EntityRelation;
|
||||
access: "source" | "target";
|
||||
self: EntityRelationAnchor;
|
||||
other: EntityRelationAnchor;
|
||||
|
||||
constructor(relation: EntityRelation, entity_name: string) {
|
||||
this.relation = relation;
|
||||
|
||||
if (relation.source.entity.name === entity_name) {
|
||||
this.access = "source";
|
||||
this.self = relation.source;
|
||||
this.other = relation.target;
|
||||
} else if (relation.target.entity.name === entity_name) {
|
||||
this.access = "target";
|
||||
this.self = relation.target;
|
||||
this.other = relation.source;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Entity "${entity_name}" is not part of the relation ` +
|
||||
`"${relation.source.entity.name} <-> ${relation.target.entity.name}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: add to respective relations
|
||||
getMutationInfo() {
|
||||
const ops: Record<MutationOperation, boolean> = {
|
||||
$set: false,
|
||||
$create: false,
|
||||
$attach: false,
|
||||
$detach: false,
|
||||
};
|
||||
|
||||
let local_field: string | undefined;
|
||||
let primary: string | undefined;
|
||||
|
||||
switch (this.relation.type()) {
|
||||
case RelationTypes.ManyToOne:
|
||||
// only if owning side (source), target is always single (just to assure)
|
||||
if (typeof this.self.cardinality === "undefined" && this.other.cardinality === 1) {
|
||||
ops.$set = true;
|
||||
local_field = (this.relation as ManyToOneRelation).getField()?.name;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
|
||||
break;
|
||||
case RelationTypes.OneToOne:
|
||||
// only if owning side (source)
|
||||
if (this.access === "source") {
|
||||
ops.$create = true;
|
||||
ops.$set = true; // @todo: for now allow
|
||||
local_field = (this.relation as OneToOneRelation).getField()?.name;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
break;
|
||||
case RelationTypes.ManyToMany:
|
||||
if (this.access === "source") {
|
||||
ops.$attach = true;
|
||||
ops.$detach = true;
|
||||
primary = this.other.entity.getPrimaryField().name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
reference: this.other.reference,
|
||||
local_field,
|
||||
...ops,
|
||||
primary,
|
||||
cardinality: this.other.cardinality,
|
||||
relation_type: this.relation.type(),
|
||||
};
|
||||
}
|
||||
}
|
||||
121
app/src/data/relations/RelationMutator.ts
Normal file
121
app/src/data/relations/RelationMutator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelation,
|
||||
type MutationOperation,
|
||||
MutationOperations,
|
||||
RelationField
|
||||
} from "../relations";
|
||||
|
||||
export type MutationInstructionResponse = [string, PrimaryFieldType | null];
|
||||
|
||||
export class RelationMutator {
|
||||
constructor(
|
||||
protected entity: Entity,
|
||||
protected em: EntityManager<any>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns all keys that are somehow relational.
|
||||
* Includes local fields (users_id) and references (users|author)
|
||||
*
|
||||
* @param em
|
||||
* @param entity_name
|
||||
*
|
||||
* @returns string[]
|
||||
*/
|
||||
getRelationalKeys(): string[] {
|
||||
const references: string[] = [];
|
||||
this.em.relationsOf(this.entity.name).map((r) => {
|
||||
const info = r.helper(this.entity.name).getMutationInfo();
|
||||
references.push(info.reference);
|
||||
info.local_field && references.push(info.local_field);
|
||||
});
|
||||
return references;
|
||||
}
|
||||
|
||||
async persistRelationField(
|
||||
field: RelationField,
|
||||
key: string,
|
||||
value: PrimaryFieldType
|
||||
): Promise<MutationInstructionResponse> {
|
||||
// allow empty if field is not required
|
||||
if (value === null && !field.isRequired()) {
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
// make sure it's a primitive value
|
||||
// @todo: this is not a good way of checking primitives. Null is also an object
|
||||
if (typeof value === "object") {
|
||||
console.log("value", value);
|
||||
throw new Error(`Invalid value for relation field "${key}" given, expected primitive.`);
|
||||
}
|
||||
|
||||
const query = await this.em.repository(field.target()).exists({
|
||||
[field.targetField()]: value
|
||||
});
|
||||
|
||||
if (!query.exists) {
|
||||
const idProp = field.targetField();
|
||||
throw new Error(
|
||||
`Cannot connect "${this.entity.name}.${key}" to ` +
|
||||
`"${field.target()}.${idProp}" = "${value}": not found.`
|
||||
);
|
||||
}
|
||||
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
async persistReference(
|
||||
relation: EntityRelation,
|
||||
key: string,
|
||||
value: unknown
|
||||
): Promise<void | MutationInstructionResponse> {
|
||||
if (typeof value !== "object" || value === null || typeof value === "undefined") {
|
||||
throw new Error(
|
||||
`Invalid value for relation "${key}" given, expected object to persist reference. Like '{$set: {id: 1}}'.`
|
||||
);
|
||||
}
|
||||
|
||||
const operation = Object.keys(value)[0] as MutationOperation;
|
||||
if (!MutationOperations.includes(operation)) {
|
||||
throw new Error(
|
||||
`Invalid operation "${operation}" for relation "${key}". ` +
|
||||
`Allowed: ${MutationOperations.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const payload = value[operation];
|
||||
return await relation[operation](this.em, key, payload);
|
||||
}
|
||||
|
||||
async persistRelation(key: string, value: unknown): Promise<void | MutationInstructionResponse> {
|
||||
// if field (e.g. 'user_id')
|
||||
// relation types: n:1, 1:1 (mapping entity)
|
||||
const field = this.entity.getField(key);
|
||||
if (field instanceof RelationField) {
|
||||
return this.persistRelationField(field, key, value as PrimaryFieldType);
|
||||
}
|
||||
|
||||
/**
|
||||
* If reference given, value operations are given
|
||||
*
|
||||
* Could be:
|
||||
* { $set: { id: 1 } }
|
||||
* { $set: [{ id: 1 }, { id: 2 }] }
|
||||
* { $create: { theme: "dark" } }
|
||||
* { $attach: [{ id: 1 }, { id: 2 }] }
|
||||
* { $detach: [{ id: 1 }, { id: 2 }] }
|
||||
*/
|
||||
const relation = this.em.relationOf(this.entity.name, key);
|
||||
if (relation) {
|
||||
return this.persistReference(relation, key, value);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Relation "${key}" failed to resolve on entity "${this.entity.name}": ` +
|
||||
"Unable to resolve relation origin."
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/src/data/relations/index.ts
Normal file
50
app/src/data/relations/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ManyToManyRelation, type ManyToManyRelationConfig } from "./ManyToManyRelation";
|
||||
import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRelation";
|
||||
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
||||
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
||||
import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
export * from "./EntityRelation";
|
||||
export * from "./EntityRelationAnchor";
|
||||
export * from "./RelationHelper";
|
||||
export * from "./RelationMutator";
|
||||
export * from "./RelationAccessor";
|
||||
|
||||
import {
|
||||
RelationField,
|
||||
type RelationFieldBaseConfig,
|
||||
type RelationFieldConfig,
|
||||
relationFieldConfigSchema
|
||||
} from "./RelationField";
|
||||
|
||||
export {
|
||||
OneToOneRelation,
|
||||
type OneToOneRelationConfig,
|
||||
ManyToOneRelation,
|
||||
type ManyToOneRelationConfig,
|
||||
ManyToManyRelation,
|
||||
type ManyToManyRelationConfig,
|
||||
PolymorphicRelation,
|
||||
type PolymorphicRelationConfig,
|
||||
RelationTypes,
|
||||
type RelationType,
|
||||
// field
|
||||
RelationField,
|
||||
relationFieldConfigSchema,
|
||||
type RelationFieldBaseConfig,
|
||||
type RelationFieldConfig
|
||||
};
|
||||
|
||||
export const RelationClassMap = {
|
||||
[RelationTypes.OneToOne]: { schema: OneToOneRelation.schema, cls: OneToOneRelation },
|
||||
[RelationTypes.ManyToOne]: { schema: ManyToOneRelation.schema, cls: ManyToOneRelation },
|
||||
[RelationTypes.ManyToMany]: { schema: ManyToManyRelation.schema, cls: ManyToManyRelation },
|
||||
[RelationTypes.Polymorphic]: {
|
||||
schema: PolymorphicRelation.schema,
|
||||
cls: PolymorphicRelation
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const RelationFieldClassMap = {
|
||||
relation: { schema: relationFieldConfigSchema, field: RelationField }
|
||||
} as const;
|
||||
7
app/src/data/relations/relation-types.ts
Normal file
7
app/src/data/relations/relation-types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const RelationTypes = {
|
||||
OneToOne: "1:1",
|
||||
ManyToOne: "n:1",
|
||||
ManyToMany: "m:n",
|
||||
Polymorphic: "poly",
|
||||
} as const;
|
||||
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
||||
Reference in New Issue
Block a user