import type { ExpressionBuilder } from "kysely"; import type { Entity, EntityManager } from "../entities"; import { NumberField, TextField } from "../fields"; import type { RepoQuery } from "../server/query"; import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { type RelationType, RelationTypes } from "./relation-types"; import { s } from "bknd/utils"; import type { PrimaryFieldType } from "bknd"; export type PolymorphicRelationConfig = s.Static; // @todo: what about cascades? export class PolymorphicRelation extends EntityRelation { static override schema = s.strictObject({ targetCardinality: s.number().optional(), ...EntityRelation.schema.properties, }); constructor(source: Entity, target: Entity, config: Partial = {}) { 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: PrimaryFieldType): Partial { const info = this.queryInfo(entity); return { where: { [this.getReferenceField().name]: info.reference_other, [this.getEntityIdField().name]: id, }, }; } buildWith(entity: Entity) { const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity); return (eb: ExpressionBuilder) => eb .selectFrom(other.entity.name) .where(whereLhs, "=", reference) .whereRef(entityRef, "=", otherRef) .$if(other.cardinality === 1, (qb) => qb.limit(1)); } 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(): TextField { return new TextField("entity_id", { hidden: true, fillable: ["create"] }); } initialize(em: EntityManager) { 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); } } }