import type { PrimaryFieldType } from "bknd"; import { s, parse } from "bknd/utils"; import type { ExpressionBuilder, SelectQueryBuilder } from "kysely"; import type { Entity, EntityData, EntityManager } from "../entities"; import { type EntityRelationAnchor, type MutationInstructionResponse, RelationHelper, } from "../relations"; import type { RepoQuery } from "../server/query"; import type { RelationType } from "./relation-types"; const directions = ["source", "target"] as const; export type TDirection = (typeof directions)[number]; export type KyselyJsonFrom = any; export type KyselyQueryBuilder = SelectQueryBuilder; export type BaseRelationConfig = s.Static; // @todo: add generic type for relation config export abstract class EntityRelation< Schema extends typeof EntityRelation.schema = typeof EntityRelation.schema, > { config: s.Static; source: EntityRelationAnchor; target: EntityRelationAnchor; // @todo: add unit tests // allowed directions, used in RelationAccessor for visibility directions: TDirection[] = ["source", "target"]; static schema = s.strictObject({ mappedBy: s.string().optional(), inversedBy: s.string().optional(), required: s.boolean().optional(), }); // don't make protected, App requires it to instantiatable constructor( source: EntityRelationAnchor, target: EntityRelationAnchor, config: Partial> = {}, ) { 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): 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, reference: string, ): (eb: ExpressionBuilder) => KyselyQueryBuilder; abstract buildJoin( entity: Entity, qb: KyselyQueryBuilder, reference: string, ): KyselyQueryBuilder; getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial { 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}"`, ); } self(entity: Entity | string): EntityRelationAnchor { return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source; } 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) { 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 { return this.target.entity.name === entity.name; } abstract type(): RelationType; get required(): boolean { return !!this.config.required; } async $set( em: EntityManager, key: string, value: unknown, ): Promise { throw new Error("$set is not allowed"); } async $create( em: EntityManager, key: string, value: unknown, ): Promise { throw new Error("$create is not allowed"); } async $attach( em: EntityManager, key: string, value: unknown, ): Promise { throw new Error("$attach is not allowed"); } async $detach( em: EntityManager, key: string, value: unknown, ): Promise { 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, }; } }