mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
import invariant from "invariant";
|
|
import { singular } from "pluralize";
|
|
import type Model from "../base/Model";
|
|
import Logger from "~/utils/Logger";
|
|
|
|
/** The behavior of a relationship on deletion */
|
|
type DeleteBehavior = "cascade" | "null" | "ignore";
|
|
/** The behavior of a relationship on archival */
|
|
type ArchiveBehavior = "cascade" | "null" | "ignore";
|
|
|
|
type RelationOptions<T = Model> = {
|
|
/** Whether this relation is required. */
|
|
required?: boolean;
|
|
/** If true, this relation is an array of IDs (one-to-many). */
|
|
multiple?: boolean;
|
|
/** Behavior of this model when relationship is deleted. */
|
|
onDelete?: DeleteBehavior | ((item: T) => DeleteBehavior);
|
|
/** Behavior of this model when relationship is archived. */
|
|
onArchive?: ArchiveBehavior | ((item: T) => ArchiveBehavior);
|
|
};
|
|
|
|
type RelationProperties<T = Model> = {
|
|
/** The name of the property on the model that stores the ID of the relation. */
|
|
idKey: keyof T;
|
|
/** A function that returns the class of the relation. */
|
|
relationClassResolver: () => typeof Model;
|
|
/** Options for the relation. */
|
|
options: RelationOptions<T>;
|
|
};
|
|
|
|
type InverseRelationProperties = RelationProperties & {
|
|
/** The name of the model class that owns this relation */
|
|
modelName: string;
|
|
};
|
|
|
|
const relations = new Map<string, Map<string, RelationProperties>>(new Map());
|
|
|
|
/**
|
|
* Returns the inverse relation properties for the given model class.
|
|
*
|
|
* @param targetClass The model class to get inverse relations for.
|
|
* @returns A map of inverse relation properties keyed by the property name.
|
|
*/
|
|
export const getInverseRelationsForModelClass = (targetClass: typeof Model) => {
|
|
const inverseRelations = new Map<string, InverseRelationProperties>();
|
|
|
|
relations.forEach((relation, modelName) => {
|
|
relation.forEach((properties, propertyName) => {
|
|
try {
|
|
const resolvedClass = properties.relationClassResolver();
|
|
if (
|
|
resolvedClass &&
|
|
resolvedClass.modelName &&
|
|
resolvedClass.modelName === targetClass.modelName
|
|
) {
|
|
inverseRelations.set(`${modelName}-${propertyName}`, {
|
|
...properties,
|
|
modelName,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
Logger.error(
|
|
`Error resolving relation ${modelName}.${propertyName} for target ${targetClass.modelName}:`,
|
|
error
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
return inverseRelations;
|
|
};
|
|
|
|
export const getRelationsForModelClass = (targetClass: typeof Model) =>
|
|
relations.get(targetClass.modelName);
|
|
|
|
/**
|
|
* A decorator that records this key as a relation field on the model.
|
|
* Properties decorated with @Relation will merge and read their data from
|
|
* the associated store.
|
|
*
|
|
* @param classResolver A function that returns the class of the relation
|
|
* @param options Optional options for the relation definition
|
|
*/
|
|
export default function Relation<T extends typeof Model>(
|
|
classResolver: () => T,
|
|
options?: RelationOptions
|
|
) {
|
|
return function (target: Model, propertyKey: string) {
|
|
const idKey = options?.multiple
|
|
? `${String(singular(propertyKey))}Ids`
|
|
: `${String(propertyKey)}Id`;
|
|
|
|
// If the relation has options provided then register them in a map for later lookup. We can use
|
|
// this to determine how to update relations when a model is deleted.
|
|
//
|
|
// TODO: requestAnimationFrame is a temporary solution to a bug in rolldown compiled code that
|
|
// will place static methods _after_ decorators. Temporary fix is to delay the registration until
|
|
// the next frame.
|
|
const modelName = (target.constructor as typeof Model).modelName;
|
|
requestAnimationFrame(() => {
|
|
if (options) {
|
|
const configForClass = relations.get(modelName) ?? new Map();
|
|
configForClass.set(propertyKey, {
|
|
options,
|
|
relationClassResolver: classResolver,
|
|
idKey,
|
|
});
|
|
relations.set(modelName, configForClass);
|
|
}
|
|
});
|
|
|
|
Object.defineProperty(target, propertyKey, {
|
|
get() {
|
|
const relationClassName = classResolver().modelName;
|
|
const store =
|
|
this.store.rootStore.getStoreForModelName(relationClassName);
|
|
invariant(store, `Store for ${relationClassName} not found`);
|
|
|
|
if (options?.multiple) {
|
|
const ids: string[] | undefined = this[idKey];
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
return [];
|
|
}
|
|
return ids.map((id) => store.get(id)).filter(Boolean);
|
|
} else {
|
|
const id: string | undefined = this[idKey];
|
|
if (!id) {
|
|
return undefined;
|
|
}
|
|
return store.get(id);
|
|
}
|
|
},
|
|
set(
|
|
newValue:
|
|
| Model
|
|
| Partial<Model>
|
|
| Array<Model | Partial<Model>>
|
|
| undefined
|
|
) {
|
|
if (options?.multiple) {
|
|
if (!newValue) {
|
|
this[idKey] = [];
|
|
if (options?.required) {
|
|
throw new Error(
|
|
`Cannot set required ${String(
|
|
propertyKey
|
|
)} to undefined or empty array`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
const values = Array.isArray(newValue) ? newValue : [newValue];
|
|
this[idKey] = values.map((v) => v.id);
|
|
const relationClassName = classResolver().modelName;
|
|
const store =
|
|
this.store.rootStore.getStoreForModelName(relationClassName);
|
|
invariant(store, `Store for ${relationClassName} not found`);
|
|
values.forEach((v) => store.add(v));
|
|
} else {
|
|
if (Array.isArray(newValue)) {
|
|
throw new Error(
|
|
`Cannot set array value to single relation property ${String(
|
|
propertyKey
|
|
)}`
|
|
);
|
|
}
|
|
this[idKey] = newValue ? newValue.id : undefined;
|
|
if (newValue) {
|
|
const relationClassName = classResolver().modelName;
|
|
const store =
|
|
this.store.rootStore.getStoreForModelName(relationClassName);
|
|
invariant(store, `Store for ${relationClassName} not found`);
|
|
store.add(newValue);
|
|
} else if (options?.required) {
|
|
throw new Error(
|
|
`Cannot set required ${String(propertyKey)} to undefined`
|
|
);
|
|
}
|
|
}
|
|
},
|
|
enumerable: true,
|
|
configurable: true,
|
|
});
|
|
};
|
|
}
|