prisma-entity-framework
Version:
TypeScript Entity Framework for Prisma ORM with Active Record pattern, fluent query builder, relation graphs, batch operations, advanced CRUD, search filters, and pagination utilities.
1,440 lines (1,427 loc) • 106 kB
JavaScript
'use strict';
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/rate-limiter.ts
function createRateLimiter(options) {
switch (options.algorithm) {
case "token-bucket":
return new exports.TokenBucketRateLimiter(options);
case "sliding-window":
return new exports.TokenBucketRateLimiter(options);
default:
throw new Error(`Unknown algorithm: ${options.algorithm}`);
}
}
exports.RateLimiter = void 0; exports.TokenBucketRateLimiter = void 0;
var init_rate_limiter = __esm({
"src/rate-limiter.ts"() {
exports.RateLimiter = class {
constructor(options) {
if (options.maxQueriesPerSecond <= 0) {
throw new Error("maxQueriesPerSecond must be positive");
}
this.options = options;
}
};
exports.TokenBucketRateLimiter = class extends exports.RateLimiter {
constructor(options) {
super(options);
this.refillRate = options.maxQueriesPerSecond / 1e3;
this.bucketCapacity = options.maxQueriesPerSecond;
this.tokens = this.bucketCapacity;
this.lastRefill = Date.now();
}
/**
* Refill tokens based on elapsed time
*/
refill() {
const now = Date.now();
const elapsed = now - this.lastRefill;
const tokensToAdd = elapsed * this.refillRate;
this.tokens = Math.min(this.bucketCapacity, this.tokens + tokensToAdd);
this.lastRefill = now;
}
/**
* Acquire permission to execute a query
* Waits if no tokens are available
*/
async acquire() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return;
}
const tokensNeeded = 1 - this.tokens;
const waitTime = Math.ceil(tokensNeeded / this.refillRate);
await new Promise((resolve) => setTimeout(resolve, waitTime));
this.refill();
this.tokens -= 1;
}
/**
* Get current status
*/
getStatus() {
this.refill();
return {
available: Math.floor(this.tokens),
total: this.bucketCapacity,
utilization: 1 - this.tokens / this.bucketCapacity
};
}
/**
* Reset the rate limiter
*/
reset() {
this.tokens = this.bucketCapacity;
this.lastRefill = Date.now();
}
};
}
});
// src/config.ts
var config_exports = {};
__export(config_exports, {
configurePrisma: () => configurePrisma,
getConfig: () => getConfig,
getConnectionPoolSize: () => getConnectionPoolSize,
getMaxConcurrency: () => getMaxConcurrency,
getPrismaInstance: () => getPrismaInstance,
getRateLimiter: () => getRateLimiter,
isParallelEnabled: () => isParallelEnabled,
isPrismaConfigured: () => isPrismaConfigured,
resetPrismaConfiguration: () => resetPrismaConfiguration
});
function configurePrisma(prisma, config) {
if (!prisma) {
throw new Error("Prisma client instance is required");
}
if (config) {
if (config.maxConcurrency !== void 0) {
if (!Number.isInteger(config.maxConcurrency) || config.maxConcurrency < 1) {
throw new Error("maxConcurrency must be a positive integer");
}
}
if (config.maxQueriesPerSecond !== void 0) {
if (!Number.isFinite(config.maxQueriesPerSecond) || config.maxQueriesPerSecond <= 0) {
throw new Error("maxQueriesPerSecond must be a positive number");
}
}
globalConfig = {
...globalConfig,
...config
};
}
globalPrismaInstance = prisma;
globalRateLimiter = createRateLimiter({
maxQueriesPerSecond: globalConfig.maxQueriesPerSecond || 100,
algorithm: "token-bucket"
});
}
function getPrismaInstance() {
if (!globalPrismaInstance) {
throw new Error(
"Prisma instance not configured. Call configurePrisma(prisma) before using any entity operations."
);
}
return globalPrismaInstance;
}
function isPrismaConfigured() {
return globalPrismaInstance !== null;
}
function resetPrismaConfiguration() {
globalPrismaInstance = null;
globalRateLimiter = null;
globalConfig = {
maxConcurrency: void 0,
enableParallel: true,
maxQueriesPerSecond: 100
};
}
function getDatabaseProvider(prisma) {
try {
const datasources = prisma?._engineConfig?.datasources;
if (datasources && datasources.length > 0) {
return datasources[0].activeProvider || "unknown";
}
const url = process.env.DATABASE_URL || "";
if (url.startsWith("postgresql://") || url.startsWith("postgres://")) return "postgresql";
if (url.startsWith("mysql://")) return "mysql";
if (url.startsWith("mongodb://") || url.startsWith("mongodb+srv://")) return "mongodb";
if (url.startsWith("sqlserver://")) return "sqlserver";
if (url.includes("file:") || url.includes(".db")) return "sqlite";
return "unknown";
} catch {
return "unknown";
}
}
function getConnectionPoolSize() {
try {
const prisma = getPrismaInstance();
const datasourceUrl = process.env.DATABASE_URL;
if (datasourceUrl) {
try {
const url = new URL(datasourceUrl);
const connectionLimit = url.searchParams.get("connection_limit");
if (connectionLimit) {
const limit = parseInt(connectionLimit, 10);
if (!isNaN(limit) && limit > 0) {
return limit;
}
}
const poolSize = url.searchParams.get("pool_size");
if (poolSize) {
const size = parseInt(poolSize, 10);
if (!isNaN(size) && size > 0) {
return size;
}
}
const maxPoolSize = url.searchParams.get("maxPoolSize");
if (maxPoolSize) {
const size = parseInt(maxPoolSize, 10);
if (!isNaN(size) && size > 0) {
return size;
}
}
} catch {
}
}
const clientConfig = prisma?._engineConfig;
if (clientConfig?.datasources?.[0]?.url) {
try {
const url = new URL(clientConfig.datasources[0].url);
const connectionLimit = url.searchParams.get("connection_limit");
if (connectionLimit) {
const limit = parseInt(connectionLimit, 10);
if (!isNaN(limit) && limit > 0) {
return limit;
}
}
} catch {
}
}
const provider = getDatabaseProvider(prisma);
const defaultPoolSizes = {
postgresql: 10,
// PostgreSQL default
mysql: 10,
// MySQL default
sqlite: 1,
// SQLite is single-threaded
sqlserver: 10,
// SQL Server default
mongodb: 10
// MongoDB default
};
return defaultPoolSizes[provider] || 2;
} catch (error) {
return 2;
}
}
function getMaxConcurrency() {
if (globalConfig.maxConcurrency !== void 0) {
return globalConfig.maxConcurrency;
}
return getConnectionPoolSize();
}
function isParallelEnabled() {
if (globalConfig.enableParallel === false) {
return false;
}
const poolSize = getConnectionPoolSize();
return poolSize > 1;
}
function getConfig() {
return { ...globalConfig };
}
function getRateLimiter() {
return globalRateLimiter;
}
var globalPrismaInstance, globalConfig, globalRateLimiter;
var init_config = __esm({
"src/config.ts"() {
init_rate_limiter();
globalPrismaInstance = null;
globalConfig = {
maxConcurrency: void 0,
enableParallel: true,
maxQueriesPerSecond: 100
};
globalRateLimiter = null;
}
});
// src/index.ts
init_config();
init_rate_limiter();
// src/parallel-utils.ts
init_config();
var ParallelMetricsTracker = class {
constructor(totalOperations, concurrency) {
this.startTime = 0;
this.endTime = 0;
this.operationTimes = [];
this.totalOperations = 0;
this.concurrency = 1;
this.totalOperations = totalOperations;
this.concurrency = concurrency;
}
/**
* Start tracking
*/
start() {
this.startTime = Date.now();
}
/**
* Record an operation completion
*/
recordOperation(duration) {
this.operationTimes.push(duration);
}
/**
* Complete tracking and calculate metrics
*/
complete() {
this.endTime = Date.now();
const totalTime = this.endTime - this.startTime;
const avgOperationTime = this.operationTimes.length > 0 ? this.operationTimes.reduce((sum, t) => sum + t, 0) / this.operationTimes.length : 0;
const sequentialEstimate = avgOperationTime * this.totalOperations;
const speedupFactor = sequentialEstimate > 0 ? sequentialEstimate / totalTime : 1;
const itemsPerSecond = totalTime > 0 ? this.totalOperations / totalTime * 1e3 : 0;
const idealSpeedup = Math.min(this.concurrency, this.totalOperations);
const parallelEfficiency = idealSpeedup > 0 ? speedupFactor / idealSpeedup : 0;
const connectionUtilization = this.totalOperations >= this.concurrency ? Math.min(1, speedupFactor / this.concurrency) : this.totalOperations / this.concurrency;
return {
totalTime,
sequentialEstimate,
speedupFactor,
itemsPerSecond,
parallelEfficiency: Math.min(1, parallelEfficiency),
connectionUtilization: Math.min(1, connectionUtilization)
};
}
};
function createParallelMetrics() {
return {
totalTime: 0,
sequentialEstimate: 0,
speedupFactor: 1,
itemsPerSecond: 0,
parallelEfficiency: 0,
connectionUtilization: 0
};
}
async function executeInParallel(operations, options) {
if (operations.length === 0) {
return {
results: [],
errors: [],
metrics: createParallelMetrics()
};
}
const concurrency = options?.concurrency ?? getMaxConcurrency();
const rateLimiter = getRateLimiter();
const metricsTracker = new ParallelMetricsTracker(operations.length, concurrency);
metricsTracker.start();
const results = [];
const errors = [];
const totalOps = operations.length;
const hasProgressCallback = !!options?.onProgress;
const hasErrorCallback = !!options?.onError;
if (concurrency >= totalOps) {
const settled = await Promise.all(
operations.map(async (operation, index) => {
const operationStart = Date.now();
try {
if (rateLimiter) await rateLimiter.acquire();
const value = await operation();
metricsTracker.recordOperation(Date.now() - operationStart);
return { success: true, value };
} catch (error) {
const err = error;
if (hasErrorCallback) options.onError(err, index);
return { success: false, error: err, index };
}
})
);
for (const result of settled) {
if (result.success) {
results.push(result.value);
} else {
errors.push({ index: result.index, error: result.error });
}
}
if (hasProgressCallback) options.onProgress(totalOps, totalOps);
} else {
let completedCount = 0;
for (let i = 0; i < totalOps; i += concurrency) {
const chunkEnd = Math.min(i + concurrency, totalOps);
const chunkResults = await Promise.all(
operations.slice(i, chunkEnd).map(async (operation, offset) => {
const index = i + offset;
const operationStart = Date.now();
try {
if (rateLimiter) await rateLimiter.acquire();
const value = await operation();
metricsTracker.recordOperation(Date.now() - operationStart);
return { success: true, value };
} catch (error) {
const err = error;
if (hasErrorCallback) options.onError(err, index);
return { success: false, error: err, index };
}
})
);
for (const result of chunkResults) {
if (result.success) {
results.push(result.value);
} else {
errors.push({ index: result.index, error: result.error });
}
}
if (hasProgressCallback) {
completedCount = chunkEnd;
options.onProgress(completedCount, totalOps);
}
}
}
const metrics = metricsTracker.complete();
return {
results,
errors,
metrics
};
}
function chunkForParallel(items, batchSize, concurrency) {
if (items.length === 0) return [];
if (batchSize <= 0) throw new Error("batchSize must be positive");
if (concurrency <= 0) throw new Error("concurrency must be positive");
const chunks = [];
for (let i = 0; i < items.length; i += batchSize) {
chunks.push(items.slice(i, i + batchSize));
}
return chunks;
}
function getOptimalConcurrency(operationType, itemCount) {
const maxConcurrency = getMaxConcurrency();
if (itemCount < 100) return 1;
if (itemCount < 1e3) {
return Math.min(2, maxConcurrency);
}
if (itemCount < 1e4) {
return Math.min(4, maxConcurrency);
}
return Math.min(8, maxConcurrency);
}
function shouldUseParallel(itemCount, poolSize) {
if (itemCount < 100) {
console.warn("\u26A0\uFE0F Dataset too small for parallel execution benefit. Using sequential.");
return false;
}
if (poolSize === 1) {
console.info("\u2139\uFE0F Connection pool size is 1. Using sequential execution.");
return false;
}
return true;
}
// src/types/search.types.ts
exports.FindByFilterOptions = void 0;
((FindByFilterOptions2) => {
FindByFilterOptions2.defaultOptions = {
onlyOne: false,
relationsToInclude: [],
search: void 0,
pagination: void 0,
orderBy: void 0,
parallel: void 0,
concurrency: void 0,
rateLimit: void 0
};
})(exports.FindByFilterOptions || (exports.FindByFilterOptions = {}));
// src/data-utils.ts
var DataUtils = class {
/**
* Processes relational data by transforming nested objects and arrays into Prisma-compatible formats.
* Converts objects into `connect` or `create` structures for relational integrity.
* JSON fields and scalar arrays are preserved as-is without wrapping in connect/create.
* @param data The original data object containing relations.
* @param modelInfo Optional model information to detect JSON fields and scalar arrays
* @returns A transformed object formatted for Prisma operations.
*/
static processRelations(data, modelInfo) {
const processedData = { ...data };
const jsonFields = /* @__PURE__ */ new Set();
const scalarArrayFields = /* @__PURE__ */ new Set();
if (modelInfo?.fields) {
for (const field of modelInfo.fields) {
if (field.kind === "scalar" && (field.type === "Json" || field.type === "Bytes")) {
jsonFields.add(field.name);
}
if (field.kind === "scalar" && field.isList === true) {
scalarArrayFields.add(field.name);
}
}
}
for (const key of Object.keys(data)) {
const value = data[key];
if (!this.isObject(value)) continue;
if (jsonFields.has(key)) {
processedData[key] = value;
continue;
}
if (scalarArrayFields.has(key)) {
processedData[key] = value;
continue;
}
if (Array.isArray(value)) {
const relationArray = this.processRelationArray(value);
if (relationArray.length > 0) {
processedData[key] = { connect: relationArray };
}
} else {
processedData[key] = this.processRelationObject(value);
}
}
return processedData;
}
static isObject(val) {
return typeof val === "object" && val !== null;
}
static processRelationArray(array) {
return array.map((item) => item?.id !== void 0 ? { id: item.id } : null).filter(Boolean);
}
static processRelationObject(obj) {
if (obj?.id !== void 0) {
return { connect: { id: obj.id } };
}
return { create: { ...obj } };
}
static normalizeRelationsToFK(data, keyTransformTemplate = (key) => `${key}Id`) {
const flatData = { ...data };
for (const [key, value] of Object.entries(flatData)) {
if (typeof value === "object" && value !== null && "connect" in value && value.connect && typeof value.connect === "object" && "id" in value.connect) {
const newKey = keyTransformTemplate(key);
if (!(newKey in flatData)) {
flatData[newKey] = value.connect.id;
}
delete flatData[key];
}
}
return flatData;
}
};
// src/model-utils.ts
init_config();
var ModelUtils = class {
static {
this.MAX_DEPTH = 3;
}
/**
* Gets the dependency tree for models based on their relationships
* Returns models in topological order (dependencies first)
*/
static getModelDependencyTree(modelNames) {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
const modelDeps = [];
for (const modelName of modelNames) {
const modelMeta = runtimeDataModel.models[modelName];
if (!modelMeta) {
throw new Error(`Model "${modelName}" not found in runtime data model.`);
}
const dependencies = [];
const relationFields = Object.values(modelMeta.fields).filter(
(field) => field.kind === "object" && field.relationName && !field.isList
);
for (const field of relationFields) {
const relatedModel = field.type;
if (modelNames.includes(relatedModel) && relatedModel !== modelName) {
dependencies.push(relatedModel);
}
}
modelDeps.push({
name: modelName,
dependencies
});
}
return modelDeps;
}
/**
* Sorts models in topological order based on their dependencies
*/
static sortModelsByDependencies(models) {
const visited = /* @__PURE__ */ new Set();
const sorted = [];
function visit(modelName, visiting = /* @__PURE__ */ new Set()) {
if (visited.has(modelName)) return;
if (visiting.has(modelName)) {
throw new Error(`Circular dependency detected involving model: ${modelName}`);
}
visiting.add(modelName);
const model = models.find((m) => m.name === modelName);
if (model) {
for (const dep of model.dependencies) {
visit(dep, visiting);
}
}
visiting.delete(modelName);
visited.add(modelName);
sorted.push(modelName);
}
for (const model of models) {
visit(model.name);
}
return sorted;
}
/**
* Finds the path from a child model to a parent model through relationships
*/
static findPathToParentModel(fromModel, toModel, maxDepth = 5) {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
if (!runtimeDataModel?.models[fromModel]) {
throw new Error(`Model "${fromModel}" not found in runtime data model.`);
}
if (!runtimeDataModel?.models[toModel]) {
throw new Error(`Model "${toModel}" not found in runtime data model.`);
}
const queue = [
{ model: fromModel, path: [] }
];
const visited = /* @__PURE__ */ new Set();
while (queue.length > 0) {
const current = queue.shift();
if (current.path.length >= maxDepth) continue;
if (visited.has(current.model)) continue;
visited.add(current.model);
const modelMeta = runtimeDataModel.models[current.model];
if (!modelMeta) continue;
const relationFields = Object.values(modelMeta.fields).filter(
(field) => field.kind === "object" && field.relationName && !field.isList
).map((field) => ({
name: field.name,
type: field.type
}));
for (const field of relationFields) {
const newPath = [...current.path, field.name];
if (field.type === toModel) {
return newPath.join(".");
}
queue.push({
model: field.type,
path: newPath
});
}
}
return null;
}
/**
* Builds a nested filter object to search by a field in a parent model
*/
static buildNestedFilterToParent(fromModel, toModel, fieldName, value) {
const path = this.findPathToParentModel(fromModel, toModel);
if (!path) {
const directField = toModel.toLowerCase() + "Id";
return { [directField]: value };
}
const pathParts = path.split(".");
const filter = {};
let current = filter;
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
if (i === pathParts.length - 1) {
current[part] = { [fieldName]: value };
} else {
current[part] = {};
current = current[part];
}
}
return filter;
}
/**
* Builds include tree for nested relations based on provided configuration
*/
static async getIncludesTree(modelName, relationsToInclude = [], currentDepth = 0) {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
const getRelationalFields = (model) => {
const modelMeta = runtimeDataModel.models[model];
if (!modelMeta) throw new Error(`Model "${model}" not found in runtime data model.`);
return Object.values(modelMeta.fields).filter((field) => field.kind === "object" && field.relationName).map((field) => ({
name: field.name,
type: field.type
}));
};
const isValidField = (fields, name) => fields.find((f) => f.name === name);
const buildSubInclude = async (type, subTree, depth) => {
if (depth >= this.MAX_DEPTH) {
return true;
}
const subInclude = await this.getIncludesTree(type, subTree, depth + 1);
return Object.keys(subInclude).length > 0 ? { include: subInclude } : true;
};
const buildInclude = async (model, tree, depth) => {
const include = {};
const fields = getRelationalFields(model);
const processField = async (name, subTree) => {
const field = isValidField(fields, name);
if (!field) return;
include[name] = await buildSubInclude(field.type, subTree, depth);
};
if (tree === "*") {
for (const field of fields) {
include[field.name] = true;
}
} else if (Array.isArray(tree)) {
for (const node of tree) {
for (const [relation, subTree] of Object.entries(node)) {
await processField(relation, subTree);
}
}
}
return include;
};
return await buildInclude(modelName, relationsToInclude, currentDepth);
}
/**
* Gets all model names from Prisma runtime
*/
static getAllModelNames() {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
return Object.keys(runtimeDataModel.models);
}
/**
* Extracts unique constraints from a model using Prisma runtime
* Returns an array of field name arrays that form unique constraints
*/
static getUniqueConstraints(modelName) {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
const modelMeta = runtimeDataModel?.models[modelName];
if (!modelMeta) {
console.warn(`Model "${modelName}" not found in runtime data model.`);
return [];
}
const uniqueConstraints = [];
if (modelMeta.uniqueIndexes && Array.isArray(modelMeta.uniqueIndexes)) {
for (const index of modelMeta.uniqueIndexes) {
if (index.fields && Array.isArray(index.fields)) {
uniqueConstraints.push(index.fields);
}
}
}
if (modelMeta.fields) {
for (const field of Object.values(modelMeta.fields)) {
if (field.isUnique && field.name && field.name !== "id") {
uniqueConstraints.push([field.name]);
}
}
}
if (modelMeta.primaryKey?.fields && Array.isArray(modelMeta.primaryKey.fields) && modelMeta.primaryKey.fields.length > 0) {
const pkFields = modelMeta.primaryKey.fields;
if (!(pkFields.length === 1 && pkFields[0] === "id")) {
uniqueConstraints.push(pkFields);
}
}
return uniqueConstraints;
}
};
// src/base-entity.ts
init_config();
// src/search/condition-utils.ts
var ConditionUtils = class {
/**
* Validates if a value is considered valid for filtering
*
* @param value - The value to validate
* @returns True if the value is valid, false otherwise
*
* @remarks
* - Returns false for: null, undefined, empty strings (including whitespace-only), empty arrays
* - Returns false for objects where all nested values are invalid
* - Returns true for: non-empty strings, numbers (including 0), booleans, non-empty arrays, valid objects
*
* @example
* ```typescript
* ConditionUtils.isValid('hello') // true
* ConditionUtils.isValid('') // false
* ConditionUtils.isValid(0) // true
* ConditionUtils.isValid([]) // false
* ConditionUtils.isValid({ key: 'val' }) // true
* ```
*/
static isValid(value) {
if (value === void 0 || value === null) return false;
if (typeof value === "string") return value.trim() !== "";
if (typeof value === "boolean") return true;
if (typeof value === "number") return true;
if (value instanceof Date) return true;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") {
const entries = Object.entries(value);
if (entries.length === 0) return false;
for (const [, val] of entries) {
if (!this.isValid(val)) return false;
}
return true;
}
return true;
}
/**
* Creates a Prisma string condition based on the search mode
*
* @param option - String search options with value and mode
* @returns Prisma condition object for string matching
*
* @remarks
* Supported modes:
* - LIKE: Creates { contains: value } for substring matching
* - STARTS_WITH: Creates { startsWith: value }
* - ENDS_WITH: Creates { endsWith: value }
* - EXACT (default): Creates { equals: value } for exact matching
*
* @example
* ```typescript
* ConditionUtils.string({ value: 'John', mode: 'LIKE' })
* // Returns: { contains: 'John' }
*
* ConditionUtils.string({ value: 'John', mode: 'STARTS_WITH' })
* // Returns: { startsWith: 'John' }
* ```
*/
static string(option) {
switch (option.mode) {
case "LIKE":
return { contains: option.value };
case "STARTS_WITH":
return { startsWith: option.value };
case "ENDS_WITH":
return { endsWith: option.value };
default:
return { equals: option.value };
}
}
/**
* Creates a Prisma range condition for numeric or date filtering
*
* @param option - Range search options with min and/or max values
* @returns Prisma condition object with gte/lte operators
*
* @remarks
* - If only min is provided: returns { gte: min }
* - If only max is provided: returns { lte: max }
* - If both provided: returns { gte: min, lte: max }
* - If neither provided: returns empty object
*
* @example
* ```typescript
* ConditionUtils.range({ min: 18, max: 65 })
* // Returns: { gte: 18, lte: 65 }
*
* ConditionUtils.range({ min: new Date('2024-01-01') })
* // Returns: { gte: Date('2024-01-01') }
* ```
*/
static range(option) {
const condition = {};
if (option.min !== void 0) condition.gte = option.min;
if (option.max !== void 0) condition.lte = option.max;
return condition;
}
/**
* Creates a Prisma list condition for array matching
*
* @param option - List search options with array of values and mode
* @returns Prisma condition object based on mode
*
* @remarks
* Supported modes:
* - IN (default): Creates { in: values } for matching any value in the list
* - NOT_IN: Creates { notIn: values } for excluding values in the list
* - HAS_SOME: Creates { hasSome: values } for array fields that contain some values
* - HAS_EVERY: Creates { hasEvery: values } for array fields that contain all values
*
* @example
* ```typescript
* ConditionUtils.list({ values: ['active', 'pending'], mode: 'IN' })
* // Returns: { in: ['active', 'pending'] }
*
* ConditionUtils.list({ values: ['deleted'], mode: 'NOT_IN' })
* // Returns: { notIn: ['deleted'] }
*
* ConditionUtils.list({ values: ['tag1', 'tag2'], mode: 'HAS_SOME' })
* // Returns: { hasSome: ['tag1', 'tag2'] }
*
* ConditionUtils.list({ values: ['required1', 'required2'], mode: 'HAS_EVERY' })
* // Returns: { hasEvery: ['required1', 'required2'] }
* ```
*/
static list(option) {
const mode = option.mode || "IN";
switch (mode) {
case "NOT_IN":
return { notIn: option.values };
case "HAS_SOME":
return { hasSome: option.values };
case "HAS_EVERY":
return { hasEvery: option.values };
case "IN":
default:
return { in: option.values };
}
}
};
// src/search/object-utils.ts
var ObjectUtils = class {
/**
* Assigns a value to a nested path in an object, creating intermediate objects as needed
*
* @param target - The object to modify
* @param path - Dot-separated path to the property (e.g., 'user.profile.name')
* @param value - The value to assign
* @param modelInfo - Optional Prisma model information for relation-aware structure creation
*
* @remarks
* - Creates intermediate objects if they don't exist
* - With modelInfo: wraps new relations with 'is'/'some' based on field type
* - Without modelInfo: creates plain nested objects
* - Intelligently merges with existing Prisma filter structures (is/some)
* - Preserves existing sibling properties
* - Handles nested Prisma relation filters correctly
*
* @example
* ```typescript
* const obj = {};
* ObjectUtils.assign(obj, 'user.profile.name', 'John');
* // obj is now: { user: { profile: { name: 'John' } } }
*
* // With existing Prisma filter structure:
* const filter = { group: { is: { id: 1 } } };
* ObjectUtils.assign(filter, 'group.course.name', 'Math');
* // filter is now: { group: { is: { id: 1, course: { name: 'Math' } } } }
*
* // With modelInfo (creates proper Prisma structures):
* const filter = {};
* ObjectUtils.assign(filter, 'posts.author.name', { contains: 'John' }, modelInfo);
* // filter is now: { posts: { some: { author: { is: { name: { contains: 'John' } } } } } }
* ```
*/
static assign(target, path, value, modelInfo) {
const keys = path.split(".");
let current = target;
let currentModelInfo = modelInfo;
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
if (index === keys.length - 1) {
current[key] = value;
} else if (current[key] && typeof current[key] === "object") {
const result = this.navigateIntoExisting(current[key], key, currentModelInfo);
current = result.target;
currentModelInfo = result.modelInfo;
} else {
const result = this.createNewStructure(current, key, currentModelInfo);
current = result.target;
currentModelInfo = result.modelInfo;
}
}
}
/**
* Navigates into an existing object structure, detecting Prisma wrappers
* @private
*/
static navigateIntoExisting(obj, key, modelInfo) {
let target = obj;
if ("is" in obj && typeof obj.is === "object") {
target = obj.is;
} else if ("some" in obj && typeof obj.some === "object") {
target = obj.some;
}
return {
target,
modelInfo: this.getNextModelInfo(key, modelInfo)
};
}
/**
* Creates a new structure for a key, with Prisma awareness if modelInfo provided
* @private
*/
static createNewStructure(parent, key, modelInfo) {
if (!modelInfo) {
parent[key] = {};
return { target: parent[key], modelInfo: null };
}
const field = modelInfo?.fields?.find((f) => f.name === key);
if (field && field.kind === "object") {
const wrapper = field.isList ? "some" : "is";
parent[key] = { [wrapper]: {} };
return {
target: parent[key][wrapper],
modelInfo: this.getNextModelInfo(key, modelInfo)
};
}
parent[key] = {};
return { target: parent[key], modelInfo: null };
}
/**
* Builds a nested object from a path and value
*
* @param path - Dot-separated path (e.g., 'user.profile.name')
* @param value - The value to place at the end of the path
* @returns A nested object with the value at the specified path
*
* @remarks
* Constructs the object from the inside out, starting with the value
* and wrapping it in nested objects according to the path
*
* @example
* ```typescript
* ObjectUtils.build('user.profile.name', 'John')
* // Returns: { user: { profile: { name: 'John' } } }
*
* ObjectUtils.build('status', 'active')
* // Returns: { status: 'active' }
* ```
*/
static build(path, value) {
return path.split(".").reverse().reduce((acc, key) => ({ [key]: acc }), value);
}
/**
* Builds a nested object from a path and value with Prisma relation awareness
* Wraps array relations with 'some' and single relations with 'is'
*
* @param path - Dot-separated path (e.g., 'group.groupMembers.user.idNumber')
* @param value - The value to place at the end of the path
* @param modelInfo - Optional Prisma model information for relation detection
* @returns A nested object with proper Prisma filter wrappers
*
* @remarks
* - Without modelInfo, behaves like build() (no wrappers)
* - With modelInfo, detects relation types for each path segment
* - Array relations (list: true) are wrapped with { some: {...} }
* - Single relations are wrapped with { is: {...} }
* - Final field (not a relation) is assigned the value directly
* - Uses getPrismaInstance to resolve nested model info
*
* @example
* ```typescript
* // Without modelInfo (no wrappers):
* ObjectUtils.buildWithRelations('group.name', { contains: 'A' })
* // Returns: { group: { name: { contains: 'A' } } }
*
* // With modelInfo (array relation 'groupMembers' + single relation 'user'):
* ObjectUtils.buildWithRelations('group.groupMembers.user.idNumber', { endsWith: '123' }, modelInfo)
* // Returns: { group: { groupMembers: { some: { user: { is: { idNumber: { endsWith: '123' } } } } } } }
* ```
*/
static buildWithRelations(path, value, modelInfo) {
if (!modelInfo) {
return this.build(path, value);
}
const keys = path.split(".");
const modelInfoMap = { 0: modelInfo };
let currentModelInfo = modelInfo;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const nextModelInfo = this.getNextModelInfo(key, currentModelInfo);
modelInfoMap[i + 1] = nextModelInfo;
currentModelInfo = nextModelInfo;
}
let result = value;
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i];
const fieldModelInfo = modelInfoMap[i];
result = this.wrapFieldWithRelation(key, result, fieldModelInfo);
}
return result;
}
/**
* Wraps a field value with Prisma relation operators if it's a relation
*
* @param key - Field name
* @param value - Value to wrap
* @param modelInfo - Current model information
* @returns Wrapped value or plain object
* @private
*/
static wrapFieldWithRelation(key, value, modelInfo) {
const field = modelInfo?.fields?.find((f) => f.name === key);
if (!field || field.kind !== "object") {
return { [key]: value };
}
return field.isList ? { [key]: { some: value } } : { [key]: { is: value } };
}
/**
* Gets the model info for a related model
*
* @param fieldName - Name of the relation field
* @param modelInfo - Current model information
* @returns Model info for the related model or null
* @private
*/
static getNextModelInfo(fieldName, modelInfo) {
if (!modelInfo?.fields) return null;
const field = modelInfo.fields.find((f) => f.name === fieldName);
if (!field || field.kind !== "object") return null;
try {
const { getPrismaInstance: getPrismaInstance2 } = (init_config(), __toCommonJS(config_exports));
const prisma = getPrismaInstance2();
const runtimeDataModel = prisma._runtimeDataModel;
const relatedModelName = field.type;
return runtimeDataModel?.models?.[relatedModelName] || null;
} catch {
return null;
}
}
/**
* Gets a value from a nested object using a dot-separated path
*
* @param obj - The object to read from
* @param path - Dot-separated path to the property (e.g., 'user.profile.name')
* @returns The value at the specified path, or undefined if not found
*
* @remarks
* - Returns undefined if any part of the path doesn't exist
* - Handles null/undefined values gracefully in the path
*
* @example
* ```typescript
* const obj = { user: { profile: { name: 'John' } } };
* ObjectUtils.get(obj, 'user.profile.name') // 'John'
* ObjectUtils.get(obj, 'user.age') // undefined
* ```
*/
static get(obj, path) {
return path.split(".").reduce((acc, key) => acc?.[key], obj);
}
/**
* Removes properties at specified paths and cleans up empty parent objects
*
* @param filter - The object to modify
* @param paths - Set of dot-separated paths to remove
*
* @remarks
* - Removes the property at each specified path
* - Recursively removes parent objects that become empty after deletion
* - Leaves non-empty parent objects intact
* - Handles non-existent paths gracefully
*
* @example
* ```typescript
* const obj = { user: { name: 'John', age: 30 }, status: 'active' };
* ObjectUtils.clean(obj, new Set(['user.age']));
* // obj is now: { user: { name: 'John' }, status: 'active' }
*
* const obj2 = { user: { name: 'John' } };
* ObjectUtils.clean(obj2, new Set(['user.name']));
* // obj2 is now: {} (empty parent removed)
* ```
*/
static clean(filter, paths) {
for (const fullPath of paths) {
const { parent, lastKey } = this.getParentAndKey(filter, fullPath);
if (!parent || !(lastKey in parent)) continue;
delete parent[lastKey];
this.cleanEmptyAncestors(filter, fullPath);
}
}
/**
* Gets the parent object and key for a given path
*
* @param obj - The object to traverse
* @param path - Dot-separated path to navigate
* @returns Object containing the parent object and the last key, or undefined parent if path invalid
* @private
*
* @remarks
* Used internally by clean() to locate the parent of a property to be deleted
*/
static getParentAndKey(obj, path) {
const keys = path.split(".");
const lastKey = keys.at(-1);
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (typeof current[key] !== "object") {
return { parent: void 0, lastKey };
}
current = current[key];
}
return { parent: current, lastKey };
}
/**
* Recursively removes empty ancestor objects after a property deletion
*
* @param obj - The root object
* @param path - The path where deletion occurred
* @private
*
* @remarks
* Walks up the path hierarchy, removing objects that have become empty
* Stops when it encounters a non-empty object or reaches the root
*/
static cleanEmptyAncestors(obj, path) {
const keys = path.split(".");
for (let depth = keys.length - 1; depth > 0; depth--) {
const currentPath = keys.slice(0, depth).join(".");
const currentKey = keys[depth - 1];
const currentObj = currentPath.includes(".") ? this.get(obj, currentPath.split(".").slice(0, -1).join(".")) : obj;
if (!currentObj) break;
const targetObj = currentObj[currentKey];
if (targetObj && typeof targetObj === "object" && !Array.isArray(targetObj) && Object.keys(targetObj).length === 0) {
delete currentObj[currentKey];
} else {
break;
}
}
}
};
// src/search/search-builder.ts
var SearchBuilder = class {
/**
* Builds a complete search filter by applying string, range, and list searches
*
* @param baseFilter - The base filter object to extend
* @param options - Search options containing string, range, and list searches
* @param modelInfo - Optional Prisma model information for relation detection
* @returns Combined filter object with all search conditions applied
*
* @example
* ```typescript
* const filter = SearchBuilder.build(
* { isActive: true },
* {
* stringSearch: [{ keys: ['name'], value: 'John', mode: 'LIKE' }],
* rangeSearch: [{ keys: ['age'], min: 18, max: 65 }]
* }
* );
* // Returns: { isActive: true, name: { contains: 'John' }, age: { gte: 18, lte: 65 } }
* ```
*/
static build(baseFilter, options, modelInfo) {
const filter = { ...baseFilter };
if (options.stringSearch) this.apply(filter, options.stringSearch, ConditionUtils.string, modelInfo);
if (options.rangeSearch) this.apply(filter, options.rangeSearch, ConditionUtils.range, modelInfo);
if (options.listSearch) this.apply(filter, options.listSearch, ConditionUtils.list, modelInfo);
return filter;
}
/**
* Applies a specific type of search condition to the filter
* Handles both AND and OR grouping of conditions
*
* @template T - Type of search condition with optional keys and grouping
* @param filter - The filter object to modify
* @param conditions - Array of search conditions to apply
* @param buildCondition - Function to build the condition object from the search option
* @param modelInfo - Optional Prisma model information for relation detection
* @private
*
* @remarks
* - Skips invalid conditions using ConditionUtils.isValid()
* - For OR grouping: adds conditions to filter.OR array and tracks paths for cleanup
* - For AND grouping: assigns conditions directly to filter using ObjectUtils.assign()
* - Cleans up duplicate paths that appear in OR conditions
*/
static apply(filter, conditions, buildCondition, modelInfo) {
const orPaths = /* @__PURE__ */ new Set();
for (const option of conditions) {
const keys = option.keys ?? [];
const grouping = option.grouping ?? "and";
const condition = buildCondition(option);
if (!ConditionUtils.isValid(condition)) continue;
if (grouping === "or") {
filter.OR = filter.OR ?? [];
for (const path of keys) {
filter.OR.push(ObjectUtils.buildWithRelations(path, condition, modelInfo));
orPaths.add(path);
}
} else {
for (const path of keys) {
ObjectUtils.assign(filter, path, condition, modelInfo);
}
}
}
ObjectUtils.clean(filter, orPaths);
}
};
// src/search/search-utils.ts
init_config();
var SearchUtils = class {
/**
* Applies search filters to a base filter using SearchBuilder
*
* @param baseFilter - The base filter object to extend
* @param searchOptions - Search options with string, range, and list searches
* @param modelInfo - Optional Prisma model information for relation detection
* @returns Combined filter with search conditions applied
*
* @remarks
* This is a wrapper around SearchBuilder.build() for convenience
* Combines the base filter with advanced search options
*
* @example
* ```typescript
* const filter = SearchUtils.applySearchFilter(
* { isActive: true },
* {
* stringSearch: [{ keys: ['name'], value: 'John', mode: 'LIKE' }]
* }
* );
* ```
*/
static applySearchFilter(baseFilter, searchOptions, modelInfo) {
return SearchBuilder.build(baseFilter, searchOptions, modelInfo);
}
/**
* Converts plain filter objects into Prisma-compatible query conditions
* Automatically detects field types and applies appropriate conditions
*
* @param input - Plain object with field values to filter by
* @param modelInfo - Optional Prisma model information for relation detection
* @returns Prisma-compatible filter object
*
* @remarks
* Automatic condition mapping:
* - Strings/Numbers/Dates → { equals: value }
* - Arrays → { hasEvery: value }
* - Nested objects → { is: {...} } for single relations
* - Nested objects → { some: {...} } for array relations (when modelInfo provided)
* - Skips null, undefined, empty strings, and empty arrays
*
* @example
* ```typescript
* SearchUtils.applyDefaultFilters({ name: 'John', age: 30 })
* // Returns: { name: { equals: 'John' }, age: { equals: 30 } }
*
* SearchUtils.applyDefaultFilters({ author: { name: 'John' } })
* // Returns: { author: { is: { name: { equals: 'John' } } } }
* ```
*/
static applyDefaultFilters(input, modelInfo) {
const output = {};
for (const [key, value] of Object.entries(input)) {
if (!ConditionUtils.isValid(value)) continue;
const condition = this.buildDefaultCondition(value, key, modelInfo);
if (!condition) continue;
ObjectUtils.assign(output, key, condition);
}
return output;
}
/**
* Builds a default condition based on value type
*
* @param value - The value to create a condition for
* @param fieldName - Optional field name for relation detection
* @param modelInfo - Optional model information for relation type detection
* @returns Prisma condition object or undefined for invalid values
* @private
*
* @remarks
* - Scalars (string/number/boolean/Date) → { equals: value }
* - Arrays → { hasEvery: value } (or undefined if empty)
* - Objects → { is: {...} } or { some: {...} } depending on relation type
*/
static buildDefaultCondition(value, fieldName, modelInfo) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date) {
return { equals: value };
}
if (Array.isArray(value)) {
return value.length > 0 ? { hasEvery: value } : void 0;
}
if (typeof value === "object" && value !== null) {
const nestedModelInfo = fieldName && modelInfo ? this.getRelationModelInfo(fieldName, modelInfo) : void 0;
const nested = this.applyDefaultFilters(value, nestedModelInfo);
if (!ConditionUtils.isValid(nested)) return void 0;
if (fieldName && modelInfo && this.isArrayRelation(fieldName, modelInfo)) {
return { some: nested };
}
return { is: nested };
}
return void 0;
}
/**
* Determines if a field represents an array relation in the Prisma model
*
* @param fieldName - The name of the field to check
* @param modelInfo - Prisma model information containing field definitions
* @returns True if the field is an array relation (isList: true), false otherwise
* @private
*
* @remarks
* Used to determine whether to use 'some' or 'is' for nested object filters
* Array relations use 'some', single relations use 'is'
*/
static isArrayRelation(fieldName, modelInfo) {
if (!modelInfo?.fields) return false;
const field = modelInfo.fields.find((f) => f.name === fieldName);
if (!field) return false;
return field.kind === "object" && field.isList === true;
}
/**
* Gets the model info for a related field
*
* @param fieldName - The name of the relation field
* @param modelInfo - Parent model information
* @returns Model info for the related model, or undefined if not found
* @private
*/
static getRelationModelInfo(fieldName, modelInfo) {
if (!modelInfo?.fields) return void 0;
const field = modelInfo.fields.find((f) => f.name === fieldName);
if (!field || field.kind !== "object") return void 0;
const relatedModelName = field.type;
try {
const prisma = getPrismaInstance();
const runtimeDataModel = prisma._runtimeDataModel;
if (!runtimeDataModel?.models?.[relatedModelName]) {
return void 0;
}
return runtimeDataModel.models[relatedModelName];
} catch (error) {
return void 0;
}
}
/**
* Generates string search options for all string fields in a filter object
*
* @param filters - Object with field values to create search options from
* @param mode - Search mode to apply (default: 'EXACT')
* @param grouping - Whether to use 'and' or 'or' grouping (default: 'and')
* @returns Array of string search options for all non-empty string fields
*
* @remarks
* - Only processes string fields with non-empty values
* - Useful for quickly creating search options from form data
* - Each field gets its own search option
*
* @example
* ```typescript
* SearchUtils.getCustomSearchOptionsForAll(
* { name: 'John', email: 'john@example.com' },
* 'LIKE',
* 'or'
* );
* // Returns: [
* // { keys: ['name'], value: 'John', mode: 'LIKE', grouping: 'or' },
* // { keys: ['email'], value: 'john@example.com', mode: 'LIKE', grouping: 'or' }
* // ]
* ```
*/
static getCustomSearchOptionsForAll(filters, mode = "EXACT", grouping = "and") {
return Object.entries(filters).filter(([_, v]) => typeof v === "string" && v.trim() !== "").map(([k, v]) => ({ keys: [