public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View 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
};
}
}

View 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,
};
}
}

View 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("_");
}
}

View 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;
}
}

View 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}".`);
}
}
}

View 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);
}
}
}

View 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);
}
}

View 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}`
})
);
}
}

View 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(),
};
}
}

View 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."
);
}
}

View 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;

View 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];