Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | 3x 3x 3x 3x 3x 3x 3x 3x 73x 73x 73x | import { eq, and } from "drizzle-orm";
import { AnyPgColumn } from "drizzle-orm/pg-core";
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
import { Entity, EntityCollection, Properties, Relation } from "@rebasepro/types";
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
import { DrizzleConditionBuilder } from "../../utils/drizzle-conditions";
import {
getCollectionByPath,
getTableForCollection,
getPrimaryKeys,
parseIdValues,
buildCompositeId
} from "./entity-helpers";
import { sanitizeAndConvertDates, serializeDataToServer } from "../data-transformer";
import { RelationService } from "./RelationService";
import { EntityFetchService } from "./EntityFetchService";
import { DrizzleClient } from "../interfaces";
import { BackendCollectionRegistry } from "../../collections/BackendCollectionRegistry";
/**
* Service for handling all entity write operations.
* Handles saving, deleting, and updating entities.
*/
export class EntityPersistService {
private relationService: RelationService;
private fetchService: EntityFetchService;
constructor(private db: DrizzleClient, private registry: BackendCollectionRegistry) {
this.relationService = new RelationService(db, registry);
this.fetchService = new EntityFetchService(db, registry);
}
/**
* Delete an entity by ID
*/
async deleteEntity(collectionPath: string, entityId: string | number, _databaseId?: string): Promise<void> {
const collection = getCollectionByPath(collectionPath, this.registry);
const table = getTableForCollection(collection, this.registry);
const idInfoArray = getPrimaryKeys(collection, this.registry);
const idInfo = idInfoArray[0];
const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
Iif (!idField) {
throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
}
const parsedIdObj = parseIdValues(entityId, idInfoArray);
const parsedId = parsedIdObj[idInfo.fieldName];
await this.db
.delete(table)
.where(eq(idField, parsedId));
}
/**
* Save an entity (create or update)
*/
async saveEntity<M extends Record<string, any>>(
collectionPath: string,
values: Partial<M>,
entityId?: string | number,
databaseId?: string
): Promise<Entity<M>> {
// If saving under a nested relation path, resolve the parent and inject FK
let effectiveCollectionPath = collectionPath;
const effectiveValues: Partial<M> = { ...values };
Iif (collectionPath.includes("/")) {
const segments = collectionPath.split("/").filter(Boolean);
Iif (segments.length >= 3 && segments.length % 2 === 1) {
const rootSegment = segments[0];
let currentCollection = getCollectionByPath(rootSegment, this.registry);
let currentEntityId: string | number = segments[1];
for (let i = 2; i < segments.length; i += 2) {
const relationKey = segments[i];
const resolvedRelations = resolveCollectionRelations(currentCollection);
const relation = resolvedRelations[relationKey];
Iif (!relation) {
throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug || currentCollection.dbPath}'`);
}
if (i === segments.length - 1) {
const targetCollection = relation.target();
effectiveCollectionPath = targetCollection.slug ?? targetCollection.dbPath;
// Handle many-to-many with junction table
Iif (relation.cardinality === "many" && relation.through) {
const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
const parentIdInfo = parentIdInfoArray[0];
const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
(effectiveValues as Record<string, unknown>).__junction_table_info = {
parentCollection: currentCollection,
parentId: parsedParentId,
relation: relation,
relationKey: relationKey
};
break;
}
// Find the FK column that should store the parent ID
let targetColumnName: string;
if (relation.localKey) {
targetColumnName = relation.localKey;
} else if (relation.foreignKeyOnTarget) {
targetColumnName = relation.foreignKeyOnTarget;
} else if (relation.joinPath && relation.joinPath.length > 0) {
const targetTableName = getTableName(targetCollection);
const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
if (relevantJoinStep) {
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relevantJoinStep.on.to);
targetColumnName = targetColumnNames[0];
} else {
console.warn(`Could not find specific join step for target table ${targetTableName} in relation '${relationKey}'.`);
const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
targetColumnName = targetColumnNames[0];
}
} else {
throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
}
const parentIdInfoArray = getPrimaryKeys(currentCollection, this.registry);
const parentIdInfo = parentIdInfoArray[0];
const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
const existingValue = (effectiveValues as Record<string, unknown>)[targetColumnName];
Iif (existingValue !== undefined && existingValue !== null && existingValue !== parsedParentId) {
console.warn(`Overriding provided value '${existingValue}' for FK '${targetColumnName}' with path parent id '${parsedParentId}'.`);
}
(effectiveValues as Record<string, unknown>)[targetColumnName] = parsedParentId;
break;
} else {
const nextEntityId = segments[i + 1];
currentCollection = relation.target();
currentEntityId = nextEntityId;
}
}
}
}
const collection = getCollectionByPath(effectiveCollectionPath, this.registry);
const table = getTableForCollection(collection, this.registry);
const idInfoArray = getPrimaryKeys(collection, this.registry);
const primaryKeyFields = idInfoArray.map(info => info.fieldName);
// Build an object mapping required for dynamic returning
const returningKeys: Record<string, AnyPgColumn> = {};
idInfoArray.forEach(info => {
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
Iif (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
returningKeys[info.fieldName] = field;
});
// Separate relations that require special handling
const relationValues: Record<string, unknown> = {};
const otherValues: Partial<M> = { ...effectiveValues };
const resolvedRelations = resolveCollectionRelations(collection);
for (const key in resolvedRelations) {
const relation = resolvedRelations[key];
Iif (relation && relation.cardinality === "many") {
Iif (Object.prototype.hasOwnProperty.call(otherValues, key)) {
relationValues[key] = otherValues[key as keyof M];
delete otherValues[key as keyof M];
}
}
}
// Transform relations to IDs, then sanitize
const processedData = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
// Extract relation updates before sanitizing
const inverseRelationUpdates = ((processedData as Record<string, unknown>).__inverseRelationUpdates as Array<{ relationKey: string; relation: Relation; newValue: unknown; currentEntityId?: string | number; }>) || [];
const joinPathRelationUpdates = ((processedData as Record<string, unknown>).__joinPathRelationUpdates as Array<{ relationKey: string; relation: Relation; newTargetId: string | number | null; }>) || [];
const junctionTableInfo = (processedData as Record<string, unknown>).__junction_table_info as { parentCollection: EntityCollection<any, any>; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
delete (processedData as Record<string, unknown>).__inverseRelationUpdates;
delete (processedData as Record<string, unknown>).__joinPathRelationUpdates;
delete (processedData as Record<string, unknown>).__junction_table_info;
const entityData = sanitizeAndConvertDates(processedData);
const savedId = await this.db.transaction(async (tx) => {
let currentId: string | number;
if (entityId) {
// Update existing entity
currentId = entityId; // `entityId` is already the formatted composite or singular string
const idValues = parseIdValues(entityId, idInfoArray);
let updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
const conditions = [];
for (const info of idInfoArray) {
const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
conditions.push(eq(field, idValues[info.fieldName]));
}
await updateQuery.where(and(...conditions));
} else {
const dataForInsert = { ...(entityData as Record<string, unknown>) };
// Strip empty primary keys so the database defaults (e.g. uuid_gen(), auto-increment) can trigger
for (const info of idInfoArray) {
Iif (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === undefined) {
delete dataForInsert[info.fieldName];
}
}
const result = await tx
.insert(table)
.values(dataForInsert)
.returning(returningKeys);
const resultRow = result[0];
currentId = buildCompositeId(resultRow, idInfoArray);
}
// Handle inverse relation updates
Iif (inverseRelationUpdates.length > 0) {
await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
}
// Update many-to-many relations
Iif (Object.keys(relationValues).length > 0) {
await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
}
// Apply joinPath one-to-one relation updates
Iif (joinPathRelationUpdates.length > 0) {
await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
}
// Handle junction table creation for many-to-many path-based saves
Iif (junctionTableInfo && !entityId) {
await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
}
return currentId;
});
// Fetch the updated/created entity to return with proper relation objects
const finalEntity = await this.fetchService.fetchEntity<M>(collection.dbPath ?? collection.slug, savedId, databaseId);
Iif (!finalEntity) throw new Error("Could not fetch entity after save.");
return finalEntity;
}
/**
* Get the RelationService instance for external use
*/
getRelationService(): RelationService {
return this.relationService;
}
/**
* Get the FetchService instance for external use
*/
getFetchService(): EntityFetchService {
return this.fetchService;
}
}
|