@aradox/multi-orm
Version:
Type-safe ORM with multi-datasource support, row-level security, and Prisma-like API for PostgreSQL, SQL Server, and HTTP APIs
489 lines • 20.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.Stitcher = void 0;
const logger_1 = require("../utils/logger");
const computed_executor_1 = require("./computed-executor");
const cache_1 = require("./cache");
const path = __importStar(require("path"));
class Stitcher {
ir;
adapters;
includeDepth = 0;
computedExecutor = null;
queryCache;
constructor(ir, adapters, schemaPath, cacheOptions) {
this.ir = ir;
this.adapters = adapters;
this.queryCache = new cache_1.QueryCache(cacheOptions);
// Initialize computed executor if schema path is provided
if (schemaPath) {
const basePath = path.dirname(schemaPath);
this.computedExecutor = new computed_executor_1.ComputedExecutor({
basePath,
timeout: 5000,
throwOnTimeout: false,
cacheResolvers: true
});
}
}
async findMany(modelName, args) {
const model = this.ir.models[modelName];
if (!model)
throw new Error(`Model ${modelName} not found`);
// Check cache first
const cacheKey = cache_1.QueryCache.generateKey(modelName, 'findMany', args);
const cached = this.queryCache.get(cacheKey);
if (cached) {
logger_1.logger.debug('stitcher', `Returning cached result for ${modelName}.findMany`);
return cached;
}
const options = this.mergeOptions(args.$options);
const issues = [];
const startTime = Date.now();
try {
// Validate limits
if (options.limits) {
this.validateLimits(args, options.limits);
}
// Fetch root data
const adapter = this.getAdapter(model);
logger_1.logger.debug('stitcher', 'Calling adapter.findMany for model:', modelName);
logger_1.logger.debug('stitcher', 'Args:', JSON.stringify({ where: args.where, select: args.select, orderBy: args.orderBy, skip: args.skip, take: args.take }, null, 2));
let rootData = await adapter.findMany(modelName, {
where: args.where,
select: args.select,
orderBy: args.orderBy,
skip: args.skip,
take: args.take
});
logger_1.logger.debug('stitcher', 'Adapter returned', rootData.length, 'rows');
// Process includes (joins)
if (args.include && Object.keys(args.include).length > 0) {
await this.processIncludes(model, rootData, args.include, options, issues, 1);
}
// Execute computed fields
if (this.computedExecutor && rootData.length > 0) {
try {
rootData = await this.computedExecutor.executeComputedFields(rootData, modelName, model.fields, args.select);
}
catch (error) {
logger_1.logger.warn('stitcher', `Error executing computed fields for ${modelName}: ${error.message}`);
if (options.strict) {
throw error;
}
issues.push({ type: 'warning', message: `Computed field error: ${error.message}` });
}
}
const result = {
data: rootData,
$meta: {
issues: issues.length > 0 ? issues : undefined,
timings: { total: Date.now() - startTime }
}
};
// Cache the result
this.queryCache.set(cacheKey, result);
return result;
}
catch (error) {
if (options.strict) {
throw error;
}
issues.push({ type: 'error', message: error.message });
return {
data: [],
$meta: { issues }
};
}
}
async findUnique(modelName, args) {
const model = this.ir.models[modelName];
if (!model)
throw new Error(`Model ${modelName} not found`);
// Check cache first
const cacheKey = cache_1.QueryCache.generateKey(modelName, 'findUnique', args);
const cached = this.queryCache.get(cacheKey);
if (cached) {
logger_1.logger.debug('stitcher', `Returning cached result for ${modelName}.findUnique`);
return cached;
}
const options = this.mergeOptions(args.$options);
const issues = [];
try {
const adapter = this.getAdapter(model);
const data = await adapter.findUnique(modelName, {
where: args.where,
select: args.select
});
if (data && args.include) {
await this.processIncludes(model, [data], args.include, options, issues, 1);
}
// Execute computed fields
if (this.computedExecutor && data) {
try {
const result = await this.computedExecutor.executeComputedFields([data], modelName, model.fields, args.select);
const finalResult = {
data: result[0],
$meta: issues.length > 0 ? { issues } : undefined
};
// Cache the result
this.queryCache.set(cacheKey, finalResult);
return finalResult;
}
catch (error) {
logger_1.logger.warn('stitcher', `Error executing computed fields for ${modelName}: ${error.message}`);
if (options.strict) {
throw error;
}
issues.push({ type: 'warning', message: `Computed field error: ${error.message}` });
}
}
const result = {
data: data,
$meta: issues.length > 0 ? { issues } : undefined
};
// Cache the result
this.queryCache.set(cacheKey, result);
return result;
}
catch (error) {
if (options.strict)
throw error;
issues.push({ type: 'error', message: error.message });
return { data: null, $meta: { issues } };
}
}
async processIncludes(model, parentRows, include, options, issues, depth) {
const maxDepth = options.limits?.maxIncludeDepth ?? 2;
if (depth > maxDepth) {
const msg = `Max include depth ${maxDepth} exceeded`;
if (options.strict) {
throw new Error(msg);
}
issues.push({ type: 'warning', message: msg });
return;
}
for (const [fieldName, includeSpec] of Object.entries(include)) {
if (!includeSpec)
continue;
const field = model.fields[fieldName];
if (!field || !field.relation) {
issues.push({
type: 'warning',
message: `Field ${fieldName} is not a relation`,
path: [model.name, fieldName]
});
continue;
}
const childModel = this.ir.models[field.relation.model];
if (!childModel) {
issues.push({
type: 'warning',
message: `Related model ${field.relation.model} not found`,
path: [model.name, fieldName]
});
continue;
}
await this.fetchAndAttachRelation(model, parentRows, field.relation, childModel, fieldName, includeSpec, options, issues, depth);
}
}
async fetchAndAttachRelation(parentModel, parentRows, relation, childModel, fieldName, includeSpec, options, issues, depth) {
const field = parentModel.fields[fieldName];
// Gather foreign keys
const foreignKeys = parentRows
.map(row => row[relation.fields[0]])
.filter(key => key !== null && key !== undefined);
if (foreignKeys.length === 0) {
parentRows.forEach(row => (row[fieldName] = null));
return;
}
// Check fan-out limit
const maxFanOut = options.limits?.maxFanOut ?? 2000;
if (foreignKeys.length > maxFanOut) {
const msg = `Fan-out limit ${maxFanOut} exceeded (${foreignKeys.length} keys)`;
if (options.strict) {
throw new Error(msg);
}
issues.push({ type: 'warning', message: msg });
return;
}
const uniqueKeys = Array.from(new Set(foreignKeys));
const childAdapter = this.getAdapter(childModel);
// Build where clause for child fetch
const childWhere = {
[relation.references[0]]: { in: uniqueKeys }
};
logger_1.logger.debug('stitcher', 'Child where clause:', JSON.stringify(childWhere, null, 2));
logger_1.logger.debug('stitcher', 'Unique keys:', uniqueKeys);
// Merge with user-provided where from includeSpec
if (typeof includeSpec === 'object' && includeSpec.where) {
const canPushDown = this.canPushDownFilter(childAdapter, includeSpec.where);
if (!canPushDown && options.strict) {
throw new Error(`Cannot push down filter on ${childModel.name}`);
}
if (canPushDown) {
Object.assign(childWhere, includeSpec.where);
}
else {
issues.push({
type: 'warning',
message: `Filter on ${childModel.name} applied in-memory`,
path: [parentModel.name, fieldName]
});
}
}
// Fetch children (with bulk support if available)
let childRows;
try {
logger_1.logger.debug('stitcher', 'Fetching children from model:', childModel.name);
childRows = await this.fetchChildren(childModel, childAdapter, childWhere, includeSpec.select, options);
logger_1.logger.debug('stitcher', 'Fetched', childRows.length, 'child rows');
}
catch (error) {
if (options.strict)
throw error;
issues.push({
type: 'error',
message: `Failed to fetch ${childModel.name}: ${error.message}`,
path: [parentModel.name, fieldName]
});
parentRows.forEach(row => (row[fieldName] = null));
return;
}
// Post-filter if needed
if (typeof includeSpec === 'object' && includeSpec.where && !this.canPushDownFilter(childAdapter, includeSpec.where)) {
childRows = this.postFilter(childRows, includeSpec.where, options, issues);
}
// Build lookup map
const childMap = new Map();
for (const child of childRows) {
const key = child[relation.references[0]];
if (!childMap.has(key)) {
childMap.set(key, []);
}
childMap.get(key).push(child);
}
// Attach to parents
for (const parent of parentRows) {
const key = parent[relation.fields[0]];
const matches = childMap.get(key) || [];
parent[fieldName] = field.isList ? matches : (matches[0] || null);
}
// Process nested includes
if (typeof includeSpec === 'object' && includeSpec.include && childRows.length > 0) {
await this.processIncludes(childModel, childRows, includeSpec.include, options, issues, depth + 1);
}
}
async fetchChildren(model, adapter, where, select, options) {
logger_1.logger.debug('stitcher', 'fetchChildren where:', JSON.stringify(where, null, 2));
logger_1.logger.debug('stitcher', 'fetchChildren adapter capabilities:', adapter.capabilities);
// Use bulk endpoint if available and applicable
if (adapter.capabilities.bulkByIds && where.id?.in) {
return await adapter.findMany(model.name, { where, select });
}
// Otherwise, parallel fetches with concurrency control
const limit = options.limits?.maxConcurrentRequests ?? 10;
const keys = where.id?.in || where[Object.keys(where)[0]]?.in || [];
logger_1.logger.debug('stitcher', 'fetchChildren keys:', keys);
// If no keys or keys is empty, fetch all with where clause
if (keys.length === 0) {
logger_1.logger.debug('stitcher', 'fetchChildren: No keys, calling findMany');
return await adapter.findMany(model.name, { where, select });
}
// If filtering by 'id' field, use findUnique for each key (for path parameter endpoints like /posts/{id})
const filterField = Object.keys(where)[0];
if (filterField === 'id') {
logger_1.logger.debug('stitcher', 'fetchChildren: Filtering by id, using findUnique for each key');
const results = [];
for (let i = 0; i < keys.length; i += limit) {
const batch = keys.slice(i, i + limit);
const promises = batch.map((key) => adapter.findUnique(model.name, { where: { id: key }, select }).catch(() => null));
const batchResults = await Promise.all(promises);
results.push(...batchResults.filter(r => r !== null));
}
return results;
}
// For other fields (like userId), call findMany with where clause and let adapter handle it
logger_1.logger.debug('stitcher', 'fetchChildren: Filtering by', filterField, '- calling findMany');
return await adapter.findMany(model.name, { where, select });
}
canPushDownFilter(adapter, where) {
const supportedOps = adapter.capabilities.supportedOperators || [];
for (const [field, value] of Object.entries(where)) {
if (field === 'AND' || field === 'OR' || field === 'NOT') {
const nested = Array.isArray(value) ? value : [value];
if (!nested.every(w => this.canPushDownFilter(adapter, w))) {
return false;
}
}
else if (typeof value === 'object' && value !== null) {
const ops = Object.keys(value);
if (!ops.every(op => supportedOps.includes(op))) {
return false;
}
}
}
return true;
}
postFilter(rows, where, options, issues) {
const postFilterLimit = options.limits?.postFilterRowLimit ?? 10000;
if (rows.length > postFilterLimit) {
const msg = `Post-filter row limit ${postFilterLimit} exceeded`;
if (options.strict) {
throw new Error(msg);
}
issues.push({ type: 'warning', message: msg });
return rows.slice(0, postFilterLimit);
}
return rows.filter(row => this.matchesWhere(row, where));
}
matchesWhere(row, where) {
for (const [field, value] of Object.entries(where)) {
if (field === 'AND') {
if (!value.every(w => this.matchesWhere(row, w)))
return false;
}
else if (field === 'OR') {
if (!value.some(w => this.matchesWhere(row, w)))
return false;
}
else if (field === 'NOT') {
if (this.matchesWhere(row, value))
return false;
}
else if (typeof value === 'object') {
if (!this.matchesFieldFilter(row[field], value))
return false;
}
else {
if (row[field] !== value)
return false;
}
}
return true;
}
matchesFieldFilter(fieldValue, filter) {
for (const [op, value] of Object.entries(filter)) {
switch (op) {
case 'eq':
if (fieldValue !== value)
return false;
break;
case 'ne':
if (fieldValue === value)
return false;
break;
case 'in':
if (!value.includes(fieldValue))
return false;
break;
case 'notIn':
if (value.includes(fieldValue))
return false;
break;
case 'gt':
if (fieldValue <= value)
return false;
break;
case 'gte':
if (fieldValue < value)
return false;
break;
case 'lt':
if (fieldValue >= value)
return false;
break;
case 'lte':
if (fieldValue > value)
return false;
break;
case 'contains':
if (!String(fieldValue).includes(String(value)))
return false;
break;
case 'startsWith':
if (!String(fieldValue).startsWith(String(value)))
return false;
break;
case 'endsWith':
if (!String(fieldValue).endsWith(String(value)))
return false;
break;
}
}
return true;
}
validateLimits(args, limits) {
// Add validation logic as needed
}
mergeOptions(options) {
return {
strict: options?.strict ?? this.ir.config.strict,
limits: {
...this.ir.config.limits,
...options?.limits
}
};
}
getAdapter(model) {
const adapter = this.adapters.get(model.datasource);
if (!adapter) {
throw new Error(`No adapter found for datasource: ${model.datasource}`);
}
return adapter;
}
/**
* Invalidate cache for a specific model
*/
invalidateCache(modelName) {
this.queryCache.invalidatePattern(`^${modelName}:`);
logger_1.logger.debug('stitcher', `Invalidated cache for model: ${modelName}`);
}
/**
* Clear all cache
*/
clearCache() {
this.queryCache.clear();
}
/**
* Get cache statistics
*/
getCacheStats() {
return this.queryCache.stats();
}
}
exports.Stitcher = Stitcher;
//# sourceMappingURL=stitcher.js.map