facilmap/server/src/database/helpers.ts

394 wiersze
12 KiB
TypeScript

import { type AssociationOptions, Model, type ModelAttributeColumnOptions, type ModelCtor, type WhereOptions, DataTypes, type FindOptions, Op, Sequelize, type ModelStatic, type InferAttributes, type InferCreationAttributes, type CreationAttributes } from "sequelize";
import type { Line, Marker, PadId, ID, Type, Bbox } from "facilmap-types";
import Database from "./database.js";
import { cloneDeep, isEqual } from "lodash-es";
import type { PadModel } from "./pad";
import { arrayToAsyncIterator } from "../utils/streams";
const ITEMS_PER_BATCH = 5000;
// Workaround for https://github.com/sequelize/sequelize/issues/15898
export function createModel<ModelInstance extends Model<any, any>>(): ModelStatic<ModelInstance> {
return class extends Model {} as any;
}
export function getDefaultIdType(): ModelAttributeColumnOptions {
return {
type: DataTypes.INTEGER.UNSIGNED,
autoIncrement: true,
primaryKey: true
};
}
export function getVirtualLatType(): ModelAttributeColumnOptions {
return {
type: DataTypes.VIRTUAL,
get() {
return this.getDataValue("pos")?.coordinates[1];
},
set(val: number) {
const point = cloneDeep(this.getDataValue("pos")) ?? { type: "Point", coordinates: [0, 0] };
point.coordinates[1] = val;
this.setDataValue("pos", point);
}
};
}
export function getVirtualLonType(): ModelAttributeColumnOptions {
return {
type: DataTypes.VIRTUAL,
get() {
return this.getDataValue("pos")?.coordinates[0];
},
set(val: number) {
const point = cloneDeep(this.getDataValue("pos")) ?? { type: "Point", coordinates: [0, 0] };
point.coordinates[0] = val;
this.setDataValue("pos", point);
}
};
}
export function getPosType(): ModelAttributeColumnOptions {
return {
type: DataTypes.GEOMETRY('POINT', 4326),
allowNull: false,
get() {
return undefined;
},
set() {
throw new Error('Cannot set pos directly.');
}
};
}
export function getLatType(): ModelAttributeColumnOptions {
return {
type: DataTypes.FLOAT(9, 6),
allowNull: false,
validate: {
min: -90,
max: 90
}
};
}
export function getLonType(): ModelAttributeColumnOptions {
return {
type: DataTypes.FLOAT(9, 6),
allowNull: false,
validate: {
min: -180,
max: 180
}
};
}
export interface DataModel extends Model<InferAttributes<DataModel>, InferCreationAttributes<DataModel>> {
id: ID;
name: string;
value: string;
}
export const dataDefinition = {
id: getDefaultIdType(),
"name" : { type: DataTypes.TEXT, allowNull: false },
"value" : { type: DataTypes.TEXT, allowNull: false }
};
export function makeNotNullForeignKey(type: string, field: string, error = false): AssociationOptions {
return {
as: type,
onUpdate: "CASCADE",
onDelete: error ? "RESTRICT" : "CASCADE",
foreignKey: { name: field, allowNull: false }
}
}
export interface BboxWithExcept extends Bbox {
except?: Bbox;
}
export default class DatabaseHelpers {
_db: Database;
constructor(db: Database) {
this._db = db;
}
async _updateObjectStyles(objects: Marker | Line | AsyncIterable<Marker | Line>): Promise<void> {
const types: Record<ID, Type> = { };
for await (const object of Symbol.asyncIterator in objects ? objects : arrayToAsyncIterator([objects])) {
const padId = object.padId;
if(!types[object.typeId]) {
types[object.typeId] = await this._db.types.getType(padId, object.typeId);
if(types[object.typeId] == null)
throw new Error("Type "+object.typeId+" does not exist.");
}
const type = types[object.typeId];
if (type.type === "line") {
await this._db.lines._updateLine(object as Line, {}, type, true);
} else {
await this._db.markers._updateMarker(object as Marker, {}, type, true);
}
}
}
async _padObjectExists(type: string, padId: PadId, id: ID): Promise<boolean> {
const entry = await this._db._conn.model(type).findOne({
where: { padId: padId, id: id },
attributes: ['id']
});
return entry != null;
}
async _getPadObject<T>(type: string, padId: PadId, id: ID): Promise<T> {
const includeData = [ "Marker", "Line" ].includes(type);
const entry = await this._db._conn.model(type).findOne({
where: { id: id, padId: padId },
include: includeData ? [ this._db._conn.model(type + "Data") ] : [ ],
nest: true
});
if(entry == null)
throw new Error(type + " " + id + " of pad " + padId + " could not be found.");
const data: any = entry.toJSON();
if(includeData) {
data.data = this._dataFromArr((data as any)[type+"Data"]);
delete (data as any)[type+"Data"];
}
return data;
}
async* _getPadObjects<T>(type: string, padId: PadId, condition?: FindOptions): AsyncIterable<T> {
const includeData = [ "Marker", "Line" ].includes(type);
if(includeData) {
condition = condition || { };
condition.include = [ ...(condition.include ? (Array.isArray(condition.include) ? condition.include : [ condition.include ]) : [ ]), this._db._conn.model(type + "Data") ];
}
const Pad = this._db.pads.PadModel.build({ id: padId } satisfies Partial<CreationAttributes<PadModel>> as any);
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const objs: Array<Model> = await (Pad as any)["get" + this._db._conn.model(type).getTableName()](condition);
for (const obj of objs) {
const d: any = obj.toJSON();
if(includeData) {
d.data = this._dataFromArr((d as any)[type+"Data"]);
delete (d as any)[type+"Data"];
}
yield d;
}
}
async _createPadObject<T>(type: string, padId: PadId, data: any): Promise<T> {
const includeData = [ "Marker", "Line" ].includes(type);
const makeHistory = [ "Marker", "Line", "View", "Type" ].includes(type);
const obj = this._db._conn.model(type).build(data);
(obj as any).padId = padId;
const result: any = (await obj.save()).toJSON();
if(includeData) {
result.data = data.data || { };
if(data.data != null)
await this._setObjectData(type, result.id, data.data);
}
if(makeHistory)
await this._db.history.addHistoryEntry(padId, { type: type as any, action: "create", objectId: result.id, objectAfter: result });
return result;
}
async _updatePadObject<T>(type: string, padId: PadId, objId: ID, data: any, _noHistory?: boolean): Promise<T> {
const includeData = [ "Marker", "Line" ].includes(type);
const makeHistory = !_noHistory && [ "Marker", "Line", "View", "Type" ].includes(type);
// Fetch the old object for the history, but also to make sure that the object exists. Unfortunately,
// we cannot rely on the return value of the update() method, as on some platforms it returns 0 even
// if the object was found (but no fields were changed)
const oldObject = await this._getPadObject(type, padId, objId);
if(Object.keys(data).length > 0 && (!includeData || !isEqual(Object.keys(data), ["data"])))
await this._db._conn.model(type).update(data, { where: { id: objId, padId: padId } });
const newObject: any = await this._getPadObject(type, padId, objId);
if(includeData) {
if (data.data != null) {
await this._setObjectData(type, objId, data.data);
newObject.data = data.data;
} else
newObject.data = await this._getObjectData(type, objId);
}
if(makeHistory)
await this._db.history.addHistoryEntry(padId, { type: type as any, action: "update", objectId: objId, objectBefore: oldObject as any, objectAfter: newObject });
return newObject;
}
async _deletePadObject<T>(type: string, padId: PadId, objId: ID): Promise<T> {
const includeData = [ "Marker", "Line" ].includes(type);
const makeHistory = [ "Marker", "Line", "View", "Type" ].includes(type);
const oldObject = await this._getPadObject<T>(type, padId, objId);
if(includeData)
await this._setObjectData(type, objId, { });
await this._db._conn.model(type).build({ id: objId }).destroy();
if(makeHistory)
await this._db.history.addHistoryEntry(padId, { type: type as any, action: "delete", objectId: objId, objectBefore: oldObject as any });
return oldObject;
}
_dataToArr<T>(data: Record<string, string>, extend: T): Array<{ name: string; value: string } & T> {
const dataArr: Array<{ name: string; value: string } & T> = [ ];
for(const i of Object.keys(data)) {
if(data[i] != null) {
dataArr.push({ name: i, value: data[i], ...extend });
}
}
return dataArr;
}
_dataFromArr(dataArr: Array<{ name: string; value: string }>): Record<string, string> {
const data: Record<string, string> = Object.create(null);
for(let i=0; i<dataArr.length; i++)
data[dataArr[i].name] = dataArr[i].value;
return data;
}
async _getObjectData(type: string, objId: ID): Promise<Record<string, string>> {
const filter: any = { };
filter[type.toLowerCase()+"Id"] = objId;
const dataArr = await this._db._conn.model(type+"Data").findAll({ where: filter });
return this._dataFromArr(dataArr as any);
}
async _setObjectData(type: string, objId: ID, data: Record<string, string>): Promise<void> {
const model = this._db._conn.model(type+"Data");
const idObj: any = { };
idObj[type.toLowerCase()+"Id"] = objId;
await model.destroy({ where: idObj});
await model.bulkCreate(this._dataToArr(data, idObj));
}
makeBboxCondition(bbox: BboxWithExcept | null | undefined, posField = "pos"): WhereOptions {
const dbType = this._db._conn.getDialect()
if(!bbox)
return { };
const conditions = [ ];
if(dbType == 'postgres') {
conditions.push(
Sequelize.where(
Sequelize.fn("ST_MakeLine", Sequelize.fn("St_Point", bbox.left, bbox.bottom), Sequelize.fn("St_Point", bbox.right, bbox.top)),
"~",
Sequelize.col(posField))
);
} else {
conditions.push(
Sequelize.fn(
"MBRContains",
Sequelize.fn("LINESTRING", Sequelize.fn("POINT", bbox.left, bbox.bottom), Sequelize.fn("POINT", bbox.right, bbox.top)),
Sequelize.col(posField)
)
);
}
if(bbox.except) {
if(dbType == 'postgres') {
conditions.push({
[Op.not]: Sequelize.where(
Sequelize.fn("St_MakeLine", Sequelize.fn("St_Point", bbox.except.left, bbox.except.bottom), Sequelize.fn("St_Point", bbox.except.right, bbox.except.top)),
"~",
Sequelize.col(posField)
)
});
} else {
conditions.push({
[Op.not]: Sequelize.fn(
"MBRContains",
Sequelize.fn("LINESTRING", Sequelize.fn("POINT", bbox.except.left, bbox.except.bottom), Sequelize.fn("POINT", bbox.except.right, bbox.except.top)),
Sequelize.col(posField)
)
});
}
}
return {
[Op.and]: conditions
};
}
async renameObjectDataField(padId: PadId, typeId: ID, rename: Record<string, { name?: string; values?: Record<string, string> }>, isLine: boolean): Promise<void> {
const objectStream = (isLine ? this._db.lines.getPadLinesByType(padId, typeId) : this._db.markers.getPadMarkersByType(padId, typeId));
for await (const object of objectStream) {
const newData = cloneDeep(object.data);
const newNames: string[] = [ ];
for(const oldName in rename) {
if(rename[oldName].name) {
newData[rename[oldName].name!] = object.data[oldName];
newNames.push(rename[oldName].name!);
if(!newNames.includes(oldName))
delete newData[oldName];
}
for(const oldValue in (rename[oldName].values || { })) {
if(object.data[oldName] == oldValue)
newData[rename[oldName].name || oldName] = rename[oldName].values![oldValue];
}
}
if(!isEqual(object.data, newData)) {
if(isLine)
await this._db.lines.updateLine(object.padId, object.id, { data: newData }, true); // Last param true to not create history entry
else
await this._db.markers.updateMarker(object.padId, object.id, { data: newData }, true); // Last param true to not create history entry
}
}
}
async _bulkCreateInBatches<T>(model: ModelCtor<Model>, data: Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>): Promise<Array<T>> {
const result: Array<any> = [];
let slice: Array<Record<string, unknown>> = [];
const createSlice = async () => {
result.push(...(await model.bulkCreate(slice)).map((it) => it.toJSON()));
slice = [];
};
for await (const item of data) {
slice.push(item);
if (slice.length >= ITEMS_PER_BATCH) {
await createSlice();
}
}
if (slice.length > 0) {
await createSlice();
}
return result;
}
}