UNPKG

agentlang

Version:

The easiest way to build the most reliable AI agents - enterprise-grade teams of AI agents that collaborate with each other and humans

1,106 lines (1,024 loc) 32.8 kB
import { DataSource, EntityManager, EntitySchema, QueryRunner, SelectQueryBuilder, TableForeignKey, } from 'typeorm'; import { logger } from '../../logger.js'; import { asTableReference, DefaultVectorDimension, modulesAsOrmSchema, OwnersSuffix, VectorSuffix, } from './dbutil.js'; import { DefaultAuthInfo, ResolverAuthInfo } from '../authinfo.js'; import { canUserCreate, canUserDelete, canUserRead, canUserUpdate } from '../../modules/auth.js'; import { Environment, GlobalEnvironment } from '../../interpreter.js'; import { Instance, InstanceAttributes, newInstanceAttributes, RbacPermissionFlag, RbacSpecification, Relationship, } from '../../module.js'; import { isString } from '../../util.js'; import { DeletedFlagAttributeName, ForceReadPermFlag, isRuntimeMode_dev, isRuntimeMode_generate_migration, isRuntimeMode_init_schema, isRuntimeMode_migration, isRuntimeMode_undo_migration, PathAttributeName, UnauthorisedError, } from '../../defs.js'; import { saveMigration } from '../../modules/core.js'; import { getAppSpec } from '../../loader.js'; export let defaultDataSource: DataSource | undefined; export class DbContext { txnId: string | undefined; authInfo: ResolverAuthInfo; private inKernelMode: boolean = false; resourceFqName: string; activeEnv: Environment; private needAuthCheckFlag: boolean = true; rbacRules: RbacSpecification[] | undefined; constructor( resourceFqName: string, authInfo: ResolverAuthInfo, activeEnv: Environment, txnId?: string, inKernelMode?: boolean, rbacRules?: RbacSpecification[] ) { this.resourceFqName = resourceFqName; this.authInfo = authInfo; this.activeEnv = activeEnv; this.txnId = txnId; if (inKernelMode !== undefined) { this.inKernelMode = inKernelMode; } this.rbacRules = rbacRules; } private static GlobalDbContext: DbContext | undefined; static getGlobalContext(): DbContext { if (DbContext.GlobalDbContext === undefined) { DbContext.GlobalDbContext = new DbContext( '', DefaultAuthInfo, GlobalEnvironment, undefined, true ); } return DbContext.GlobalDbContext; } // Shallow clone clone(): DbContext { return new DbContext( this.resourceFqName, this.authInfo, this.activeEnv, this.txnId, this.inKernelMode ); } getUserId(): string { return this.authInfo.userId; } isForDelete(): boolean { return this.authInfo.readForDelete; } isForUpdate(): boolean { return this.authInfo.readForUpdate; } setResourceFqNameFrom(inst: Instance): DbContext { this.resourceFqName = inst.getFqName(); return this; } setNeedAuthCheck(flag: boolean): DbContext { this.needAuthCheckFlag = flag; return this; } switchAuthCheck(flag: boolean): boolean { const old = this.needAuthCheckFlag; this.needAuthCheckFlag = flag; return old; } isPermitted(): boolean { return this.inKernelMode || !this.needAuthCheckFlag; } isInKernelMode(): boolean { return this.inKernelMode; } forceReadPermission(): boolean { return this.activeEnv.lookup(ForceReadPermFlag); } } export type JoinOn = { attributeName: string; operator: string; attributeValue: any; }; export function makeJoinOn(attrName: string, attrValue: any, opr: string = '='): JoinOn { return { attributeName: attrName, attributeValue: attrValue, operator: opr, }; } export type JoinClause = { tableName: string; queryObject?: object; queryValues?: object; joinType?: string; // 'join' | 'inner join' | 'left join' | 'right join' | 'full join' joinOn: JoinOn | JoinOn[]; }; export type DatabaseConfig = { type: string; host?: string; username?: string; password?: string; dbname?: string; port?: number; }; function mkDbName(): string { return process.env.AGENTLANG_DB_NAME || `db-${Date.now()}`; } function makePostgresDataSource( entities: EntitySchema[], config: DatabaseConfig | undefined ): DataSource { const synchronize = isRuntimeMode_dev() || isRuntimeMode_init_schema(); //const runMigrations = isRuntimeMode_migration() || isRuntimeMode_undo_migration() || !synchronize; return new DataSource({ type: 'postgres', host: process.env.POSTGRES_HOST || config?.host || 'localhost', port: getPostgressEnvPort() || config?.port || 5432, username: process.env.POSTGRES_USER || config?.username || 'postgres', password: process.env.POSTGRES_PASSWORD || config?.password || 'postgres', database: process.env.POSTGRES_DB || config?.dbname || 'postgres', synchronize: synchronize, migrationsRun: false, dropSchema: false, entities: entities, invalidWhereValuesBehavior: { null: 'sql-null', undefined: 'ignore', }, }); } function getPostgressEnvPort(): number | undefined { const s: string | undefined = process.env.POSTGRES_PORT; if (s) { return Number(s); } else { return undefined; } } function makeSqliteDataSource( entities: EntitySchema[], config: DatabaseConfig | undefined ): DataSource { const synchronize = isRuntimeMode_dev() || isRuntimeMode_init_schema(); //const runMigrations = isRuntimeMode_migration() || isRuntimeMode_undo_migration() || !synchronize; return new DataSource({ type: 'sqlite', database: config?.dbname || mkDbName(), synchronize: synchronize, entities: entities, migrationsRun: false, dropSchema: false, invalidWhereValuesBehavior: { null: 'sql-null', undefined: 'ignore', }, }); } async function execMigrationSql(dataSource: DataSource, sql: string[]) { const queryRunner = dataSource.createQueryRunner(); await queryRunner.startTransaction(); for (let i = 0; i < sql.length; ++i) { await queryRunner.query(sql[i]); } await queryRunner.commitTransaction(); } async function maybeHandleMigrations(dataSource: DataSource) { const is_migration = isRuntimeMode_migration(); const is_undo_migration = isRuntimeMode_undo_migration(); const is_gen_migration = isRuntimeMode_generate_migration(); if (is_migration || is_undo_migration || is_gen_migration) { const sqlInMemory = await dataSource.driver.createSchemaBuilder().log(); let ups: string[] | undefined; if (is_migration || is_gen_migration) { ups = new Array<string>(); sqlInMemory.upQueries.forEach(upQuery => { ups?.push(upQuery.query.replaceAll('`', '\\`')); }); } let downs: string[] | undefined; if (is_undo_migration || is_gen_migration) { downs = new Array<string>(); sqlInMemory.downQueries.forEach(downQuery => { downs?.push(downQuery.query.replaceAll('`', '\\`')); }); } if (is_migration && ups?.length) { await saveMigration(getAppSpec().version, ups, downs); await execMigrationSql(dataSource, ups); } else if (is_undo_migration && downs?.length) { await saveMigration(getAppSpec().version, ups, downs); await execMigrationSql(dataSource, downs); } else if (is_gen_migration) { await saveMigration(getAppSpec().version, ups, downs); } } } function isBrowser(): boolean { // window for DOM pages, self+importScripts for web workers return ( (typeof window !== 'undefined' && typeof (window as any).document !== 'undefined') || (typeof self !== 'undefined' && typeof (self as any).importScripts === 'function') ); } function defaultLocateFile(file: string): string { // Out-of-the-box: use the official CDN in browsers. if (isBrowser()) { return `https://sql.js.org/dist/${file}`; } // Node: resolve from node_modules/sql.js/dist try { /* eslint-disable-next-line @typescript-eslint/no-require-imports */ const path = require('path'); const base = require.resolve('sql.js/dist/sql-wasm.js'); return path.join(path.dirname(base), file); } catch { return file; } } function makeSqljsDataSource( entities: EntitySchema[], _config: DatabaseConfig | undefined, synchronize: boolean = true ): DataSource { return new DataSource({ type: 'sqljs', autoSave: false, sqlJsConfig: { locateFile: defaultLocateFile, }, synchronize: synchronize, entities: entities, }); } const DbType = 'sqlite'; function getDbType(config?: DatabaseConfig): string { if (config?.type) return config.type; let envType: string | undefined; try { if (typeof process !== 'undefined' && process.env) { envType = process.env.AL_DB_TYPE; } } catch {} if (envType) return envType; if (isBrowser()) return 'sqljs'; return DbType; } function getDsFunction( config: DatabaseConfig | undefined ): ( entities: EntitySchema<any>[], config: DatabaseConfig | undefined, synchronize?: boolean | undefined ) => DataSource { switch (getDbType(config)) { case 'sqlite': return makeSqliteDataSource; case 'postgres': return makePostgresDataSource; case 'sqljs': return makeSqljsDataSource; default: throw new Error(`Unsupported database type - ${config?.type}`); } } export function isUsingSqlite(): boolean { return getDbType() == 'sqlite'; } export function isUsingSqljs(): boolean { return getDbType() == 'sqljs'; } export function isVectorStoreSupported(): boolean { // Only Postgres supports pgvector return getDbType() === 'postgres'; } export async function initDatabase(config: DatabaseConfig | undefined) { if (defaultDataSource === undefined) { const mkds = getDsFunction(config); if (mkds) { const ormScm = modulesAsOrmSchema(); defaultDataSource = mkds(ormScm.entities, config) as DataSource; await defaultDataSource.initialize(); await maybeHandleMigrations(defaultDataSource); if (ormScm.fkSpecs.length > 0) { const qr = defaultDataSource.createQueryRunner(); for (let i = 0; i < ormScm.fkSpecs.length; ++i) { const fk = ormScm.fkSpecs[i]; const fkobj = new TableForeignKey({ columnNames: [fk.columnName], referencedColumnNames: [fk.targetColumnName], referencedTableName: asTableReference(fk.targetModuleName, fk.targetEntityName), onDelete: fk.onDelete, onUpdate: fk.onUpdate, }); try { await qr.createForeignKey(asTableReference(fk.moduleName, fk.entityName), fkobj); } catch (reason: any) { logger.warn(`initDatabase: ${reason}`); } } } const vectEnts = ormScm.vectorEntities.map((es: EntitySchema) => { return es.options.name; }); if (vectEnts.length > 0) { await initVectorStore(vectEnts, DbContext.getGlobalContext()); } } else { throw new Error(`Unsupported database type - ${DbType}`); } } } export async function resetDefaultDatabase() { if (defaultDataSource && defaultDataSource.isInitialized) { await defaultDataSource.destroy(); defaultDataSource = undefined; } } function ownersTable(tableName: string): string { return (tableName.replace('.', '_') + OwnersSuffix).toLowerCase(); } async function insertRowsHelper( tableName: string, rows: object[], ctx: DbContext, doUpsert: boolean ): Promise<void> { const repo = getDatasourceForTransaction(ctx.txnId).getRepository(tableName); if (doUpsert) await repo.save(rows); else await repo.insert(rows); } export async function addRowForFullTextSearch( tableName: string, id: string, vect: number[], ctx: DbContext ) { if (!isVectorStoreSupported()) return; try { const vecTableName = tableName + VectorSuffix; const qb = getDatasourceForTransaction(ctx.txnId).createQueryBuilder(); const { default: pgvector } = await import('pgvector'); await qb .insert() .into(vecTableName) .values([{ id: id, embedding: pgvector.toSql(vect) }]) .execute(); } catch (err: any) { logger.error(`Failed to add row to vector store - ${err}`); } } export async function initVectorStore(tableNames: string[], ctx: DbContext) { if (!isVectorStoreSupported()) { logger.info(`Vector store not supported for ${getDbType()}, skipping init...`); return; } let notInited = true; tableNames.forEach(async (vecTableName: string) => { const vecRepo = getDatasourceForTransaction(ctx.txnId).getRepository(vecTableName); if (notInited) { let failure = false; try { await vecRepo.query('CREATE EXTENSION IF NOT EXISTS vector'); } catch (err: any) { logger.error(`Failed to initialize vector store - ${err}`); failure = true; } if (failure) return; notInited = false; } await vecRepo.query( `CREATE TABLE IF NOT EXISTS ${vecTableName} ( id varchar PRIMARY KEY, embedding vector(${DefaultVectorDimension}), __is_deleted__ boolean default false )` ); }); } export async function vectorStoreSearch( tableName: string, searchVec: number[], limit: number, ctx: DbContext ): Promise<any> { if (!isVectorStoreSupported()) { // Not supported on sqljs/sqlite return []; } try { let hasGlobalPerms = ctx.isPermitted(); if (!hasGlobalPerms) { const userId = ctx.getUserId(); const fqName = ctx.resourceFqName; const env: Environment = ctx.activeEnv; hasGlobalPerms = await canUserRead(userId, fqName, env); } const vecTableName = tableName + VectorSuffix; const qb = getDatasourceForTransaction(ctx.txnId).getRepository(tableName).manager; const { default: pgvector } = await import('pgvector'); let ownersJoinCond: string = ''; if (!hasGlobalPerms) { const ot = ownersTable(tableName); ownersJoinCond = `inner join ${ot} on ${ot}.path = ${vecTableName}.id and ${ot}.user_id = '${ctx.authInfo.userId}' and ${ot}.r = true`; } const sql = `select ${vecTableName}.id from ${vecTableName} ${ownersJoinCond} order by embedding <-> $1 LIMIT ${limit}`; return await qb.query(sql, [pgvector.toSql(searchVec)]); } catch (err: any) { logger.error(`Vector store search failed - ${err}`); return []; } } export async function vectorStoreSearchEntryExists( tableName: string, id: string, ctx: DbContext ): Promise<boolean> { if (!isVectorStoreSupported()) return false; try { const qb = getDatasourceForTransaction(ctx.txnId).getRepository(tableName).manager; const vecTableName = tableName + VectorSuffix; const result: any[] = await qb.query(`select id from ${vecTableName} where id = $1`, [id]); return result !== null && result.length > 0; } catch (err: any) { logger.error(`Vector store search failed - ${err}`); } return false; } export async function deleteFullTextSearchEntry(tableName: string, id: string, ctx: DbContext) { if (!isVectorStoreSupported()) return; try { const qb = getDatasourceForTransaction(ctx.txnId).getRepository(tableName).manager; const vecTableName = tableName + VectorSuffix; await qb.query(`delete from ${vecTableName} where id = $1`, [id]); } catch (err: any) { logger.error(`Vector store delete failed - ${err}`); } } async function checkUserPerm( opr: RbacPermissionFlag, ctx: DbContext, instRows: object ): Promise<boolean> { let hasPerm = ctx.isPermitted(); if (!hasPerm) { const userId = ctx.getUserId(); let f: Function | undefined; switch (opr) { case RbacPermissionFlag.CREATE: f = canUserCreate; break; case RbacPermissionFlag.READ: f = canUserRead; break; case RbacPermissionFlag.UPDATE: f = canUserUpdate; break; case RbacPermissionFlag.DELETE: f = canUserDelete; break; default: f = undefined; } if (f !== undefined) { hasPerm = await f(userId, ctx.resourceFqName, ctx.activeEnv); } } if (!hasPerm) { hasPerm = await isOwnerOfParent(instRows[PathKey], ctx); } return hasPerm; } async function checkCreatePermission(ctx: DbContext, inst: Instance): Promise<boolean> { const tmpCtx = ctx.clone().setResourceFqNameFrom(inst); return await checkUserPerm( RbacPermissionFlag.CREATE, tmpCtx, Object.fromEntries(inst.attributes) ); } export async function insertRows( tableName: string, rows: object[], ctx: DbContext, doUpsert: boolean = false ): Promise<void> { let hasPerm = ctx.isPermitted(); if (!hasPerm) { hasPerm = await checkUserPerm(RbacPermissionFlag.CREATE, ctx, rows[0]); } if (hasPerm) { await insertRowsHelper(tableName, rows, ctx, doUpsert); if (!doUpsert) { if (!ctx.isInKernelMode()) { await createOwnership(tableName, rows, ctx); } else if (ctx.forceReadPermission()) { await createReadPermission(tableName, rows, ctx); } if (ctx.rbacRules) { for (let i = 0; i < ctx.rbacRules.length; ++i) { const rbacRule = ctx.rbacRules[i]; const e = rbacRule.expression; if (e) { const [selfRef, userRef] = e.lhs.startsWith('this.') ? [e.lhs, e.rhs] : [e.rhs, e.lhs]; if (userRef == 'auth.user') { const attr = selfRef.split('.')[1]; for (let j = 0; j < rows.length; ++j) { const r: any = rows[j]; const userId = r[attr]; if (userId) { await createLimitedOwnership(tableName, [r], userId, rbacRule.permissions, ctx); } } } } } } } } else { throw new UnauthorisedError({ opr: 'insert', entity: tableName }); } } export async function insertRow( tableName: string, row: object, ctx: DbContext, doUpsert: boolean ): Promise<void> { const rows: Array<object> = new Array<object>(); rows.push(row); await insertRows(tableName, rows, ctx, doUpsert); } export async function insertBetweenRow( n: string, a1: string, a2: string, node1: Instance, node2: Instance, relEntry: Relationship, ctx: DbContext ): Promise<void> { let hasPerm = await checkCreatePermission(ctx, node1); if (hasPerm) { hasPerm = await checkCreatePermission(ctx, node2); } if (hasPerm) { const attrs: InstanceAttributes = newInstanceAttributes(); const p1 = node1.attributes.get(PathAttributeName); const p2 = node2.attributes.get(PathAttributeName); attrs.set(a1, p1); attrs.set(a2, p2); attrs.set(PathAttributeName, crypto.randomUUID()); if (relEntry.isOneToMany()) { attrs.set(relEntry.joinNodesAttributeName(), `${p1}_${p2}`); } const row = Object.fromEntries(attrs); await insertRow(n, row, ctx.clone().setNeedAuthCheck(false), false); } else { throw new UnauthorisedError({ opr: 'insert', entity: n }); } } const PathKey = PathAttributeName as keyof object; const AllPerms = new Set<RbacPermissionFlag>() .add(RbacPermissionFlag.CREATE) .add(RbacPermissionFlag.READ) .add(RbacPermissionFlag.UPDATE) .add(RbacPermissionFlag.DELETE); async function createOwnership(tableName: string, rows: object[], ctx: DbContext): Promise<void> { await createLimitedOwnership(tableName, rows, ctx.authInfo.userId, AllPerms, ctx); } const ReadPermOnly = new Set<RbacPermissionFlag>().add(RbacPermissionFlag.READ); async function createReadPermission( tableName: string, rows: object[], ctx: DbContext ): Promise<void> { await createLimitedOwnership(tableName, rows, ctx.authInfo.userId, ReadPermOnly, ctx); } async function createLimitedOwnership( tableName: string, rows: object[], userId: string, perms: Set<RbacPermissionFlag>, ctx: DbContext ): Promise<void> { const ownerRows: object[] = []; rows.forEach((r: object) => { ownerRows.push({ id: crypto.randomUUID(), path: r[PathKey], user_id: userId, c: perms.has(RbacPermissionFlag.CREATE), r: perms.has(RbacPermissionFlag.READ), d: perms.has(RbacPermissionFlag.DELETE), u: perms.has(RbacPermissionFlag.UPDATE), }); }); const tname = ownersTable(tableName); await insertRowsHelper(tname, ownerRows, ctx, false); } async function isOwnerOfParent(path: string, ctx: DbContext): Promise<boolean> { const parts = path.split('/'); if (parts.length <= 2) { return false; } const parentPaths = new Array<[string, string]>(); let i = 0; let lastPath: string | undefined; while (i < parts.length - 2) { const parentName = parts[i].replace('$', '_'); const parentPath = `${lastPath ? lastPath + '/' : ''}${parts[i]}/${parts[i + 1]}`; lastPath = `${parentPath}/${parts[i + 2]}`; parentPaths.push([parentName, parentPath]); i += 3; } if (parentPaths.length == 0) { return false; } for (let i = 0; i < parentPaths.length; ++i) { const [parentName, parentPath] = parentPaths[i]; const result = await isOwner(parentName, parentPath, ctx); if (result) return result; } return false; } async function isOwner(parentName: string, instPath: string, ctx: DbContext): Promise<boolean> { const userId = ctx.getUserId(); const tabName = ownersTable(parentName); const alias = tabName; const query = [ `${alias}.path = '${instPath}'`, `${alias}.user_id = '${userId}'`, `${alias}.type = 'o'`, ]; let result: any = undefined; const sq: SelectQueryBuilder<any> = getDatasourceForTransaction(ctx.txnId) .createQueryBuilder() .select() .from(tabName, alias) .where(query.join(' AND ')); try { await sq.getRawMany().then((r: any) => (result = r)); } catch (reason: any) { logger.error(`Failed to check ownership on parent ${parentName} - ${reason}`); } if (result === undefined || result.length === 0) { return false; } return true; } export async function upsertRows(tableName: string, rows: object[], ctx: DbContext): Promise<void> { await insertRows(tableName, rows, ctx, true); } export async function upsertRow(tableName: string, row: object, ctx: DbContext): Promise<void> { const rows: Array<object> = new Array<object>(); rows.push(row); await upsertRows(tableName, rows, ctx); } export async function updateRow( tableName: string, queryObj: object, queryVals: object, updateObj: object, ctx: DbContext ): Promise<boolean> { await getDatasourceForTransaction(ctx.txnId) .createQueryBuilder() .update(tableName) .set(updateObj) .where(objectToWhereClause(queryObj, queryVals), queryVals) .execute(); return true; } type QueryObjectEntry = [string, any]; export type QueryObject = Array<QueryObjectEntry>; function queryObjectAsWhereClause(qobj: QueryObject): string { const ss: Array<string> = []; qobj.forEach((kv: QueryObjectEntry) => { const k = kv[0]; ss.push(`${k} = :${k}`); }); return ss.join(' AND '); } export async function hardDeleteRow(tableName: string, queryObject: QueryObject, ctx: DbContext) { const clause = queryObjectAsWhereClause(queryObject); await getDatasourceForTransaction(ctx.txnId) .createQueryBuilder() .delete() .from(tableName) .where(clause, Object.fromEntries(queryObject)) .execute(); return true; } function mkBetweenClause(tableName: string | undefined, k: string, queryVals: any): string { const ov = queryVals[k]; if (ov instanceof Array) { const isstr = isString(ov[0]); const v1 = isstr ? `'${ov[0]}'` : ov[0]; const v2 = isstr ? `'${ov[1]}'` : ov[1]; const s = tableName ? `"${tableName}"."${k}" BETWEEN ${v1} AND ${v2}` : `"${k}" BETWEEN ${v1} AND ${v2}`; delete queryVals[k]; return s; } else { throw new Error(`between requires an array argument, not ${ov}`); } } function objectToWhereClause(queryObj: object, queryVals: any, tableName?: string): string { const clauses: Array<string> = new Array<string>(); Object.entries(queryObj).forEach((value: [string, any]) => { let op: string = value[1] as string; const k = value[0]; const isnullcheck = queryVals[k] === null; if (isnullcheck) { if (op === '=') { op = 'IS'; } else if (op === '<>' || op === '!=') { op = 'IS NOT'; } else { throw new Error(`Operator ${op} cannot be appplied to SQL NULL`); } } const v = isnullcheck ? 'NULL' : `:${k}`; const clause = op == 'between' ? mkBetweenClause(tableName, k, queryVals) : tableName ? `"${tableName}"."${k}" ${op} ${v}` : `"${k}" ${op} ${v}`; clauses.push(clause); }); return clauses.join(' AND '); } function objectToRawWhereClause(queryObj: object, queryVals: any, tableName?: string): string { const clauses: Array<string> = new Array<string>(); Object.entries(queryObj).forEach((value: [string, any]) => { let op: string = value[1] as string; const k: string = value[0]; if (queryVals[k] === null) { if (op === '=') { op = 'IS'; } else if (op === '<>' || op === '!=') { op = 'IS NOT'; } else { throw new Error(`Operator ${op} cannot be appplied to SQL NULL`); } } let clause = ''; if (op == 'between') { clause = mkBetweenClause(tableName, k, queryVals); } else { const ov: any = queryVals[k]; const v = isString(ov) ? `'${ov}'` : ov; clause = tableName ? `"${tableName}"."${k}" ${op} ${v}` : `"${k}" ${op} ${v}`; } clauses.push(clause); }); if (clauses.length > 0) { return clauses.join(' AND '); } else { return ''; } } export async function getMany( tableName: string, queryObj: object | undefined, queryVals: object | undefined, distinct: boolean, ctx: DbContext ): Promise<any> { const alias: string = tableName.toLowerCase(); const queryStr: string = withNotDeletedClause( alias, queryObj !== undefined ? objectToWhereClause(queryObj, queryVals, alias) : '' ); let ownersJoinCond: string[] | undefined; let ot: string = ''; let otAlias: string = ''; if (!ctx.isPermitted()) { const userId = ctx.getUserId(); const fqName = ctx.resourceFqName; const env: Environment = ctx.activeEnv; let hasGlobalPerms = await canUserRead(userId, fqName, env); if (hasGlobalPerms) { if (ctx.isForUpdate()) { hasGlobalPerms = await canUserUpdate(userId, fqName, env); } else if (ctx.isForDelete()) { hasGlobalPerms = await canUserDelete(userId, fqName, env); } } if (!hasGlobalPerms) { ot = ownersTable(tableName); otAlias = ot.toLowerCase(); ownersJoinCond = [ `${otAlias}.path = ${alias}.${PathAttributeName}`, `${otAlias}.user_id = '${ctx.authInfo.userId}'`, `${otAlias}.r = true`, ]; if (ctx.isForUpdate()) { ownersJoinCond.push(`${otAlias}.u = true`); } if (ctx.isForDelete()) { ownersJoinCond.push(`${otAlias}.d = true`); } } } const qb: SelectQueryBuilder<any> = getDatasourceForTransaction(ctx.txnId) .getRepository(tableName) .createQueryBuilder(); if (ownersJoinCond) { qb.innerJoin(ot, otAlias, ownersJoinCond.join(' AND ')); } if (distinct) { qb.distinct(true); } qb.where(queryStr, queryVals); return await qb.getMany(); } export async function getManyByJoin( tableName: string, queryObj: object | undefined, queryVals: object | undefined, joinClauses: JoinClause[], intoSpec: Map<string, string>, distinct: boolean, ctx: DbContext ): Promise<any> { const alias: string = tableName.toLowerCase(); const queryStr: string = withNotDeletedClause( alias, queryObj !== undefined ? objectToRawWhereClause(queryObj, queryVals, alias) : '' ); let ot: string = ''; let otAlias: string = ''; if (!ctx.isPermitted()) { const userId = ctx.getUserId(); const fqName = ctx.resourceFqName; const env: Environment = ctx.activeEnv; const hasGlobalPerms = await canUserRead(userId, fqName, env); if (!hasGlobalPerms) { ot = ownersTable(tableName); otAlias = ot.toLowerCase(); joinClauses.push({ tableName: otAlias, joinOn: [ makeJoinOn(`${otAlias}.path`, `${alias}.${PathAttributeName}`), makeJoinOn(`${otAlias}.user_id`, `'${ctx.authInfo.userId}'`), makeJoinOn(`${otAlias}.r`, true), ], }); } } const joinSql = new Array<string>(); joinClauses.forEach((jc: JoinClause) => { const joinType = jc.joinType ? jc.joinType : 'inner join'; joinSql.push( `${joinType} ${jc.tableName} as ${jc.tableName} on ${joinOnAsSql(jc.joinOn)} AND ${jc.tableName}.${DeletedFlagAttributeName} = false` ); if (jc.queryObject) { const q = objectToRawWhereClause(jc.queryObject, jc.queryValues, jc.tableName); if (q.length > 0) { joinSql.push(` AND ${q}`); } } }); const sql = `SELECT ${distinct ? 'DISTINCT' : ''} ${intoSpecToSql(intoSpec)} FROM ${tableName} ${joinSql.join('\n')} WHERE ${queryStr}`; logger.debug(`Join Query: ${sql}`); const qb = getDatasourceForTransaction(ctx.txnId).getRepository(tableName).manager; return await qb.query(sql); } function intoSpecToSql(intoSpec: Map<string, string>): string { const cols = new Array<string>(); intoSpec.forEach((v: string, k: string) => { cols.push(`${v} AS "${k}"`); }); return cols.join(', '); } function joinOnAsSql(joinOn: JoinOn | JoinOn[]): string { if (joinOn instanceof Array) { return joinOn.map(joinOnAsSql).join(' AND '); } else { return `${joinOn.attributeName} ${joinOn.operator} ${joinOn.attributeValue}`; } } function notDeletedClause(alias: string): string { return `${alias}.${DeletedFlagAttributeName} = false`; } function withNotDeletedClause(alias: string, sql: string): string { if (sql == '') { return notDeletedClause(alias); } else { return `${sql} AND ${notDeletedClause(alias)}`; } } export type BetweenConnectionInfo = { connectionTable: string; fromColumn: string; fromValue: string; toColumn: string; toRef: string; }; function buildQueryFromConnnectionInfo( connAlias: string, mainAlias: string, connInfo: BetweenConnectionInfo ): string { return `${connAlias}.${connInfo.fromColumn} = ${connInfo.fromValue} AND ${connAlias}.${connInfo.toColumn} = ${mainAlias}.${connInfo.toRef}`; } export async function getAllConnected( tableName: string, queryObj: object, queryVals: object, connInfo: BetweenConnectionInfo, ctx: DbContext ) { const alias: string = tableName.toLowerCase(); const connAlias: string = connInfo.connectionTable.toLowerCase(); const qb = getDatasourceForTransaction(ctx.txnId) .createQueryBuilder() .select() .from(tableName, alias) .where(objectToWhereClause(queryObj, alias), queryVals) .innerJoin( connInfo.connectionTable, connAlias, buildQueryFromConnnectionInfo(connAlias, alias, connInfo) ); return await qb.getRawMany(); } const transactionsDb: Map<string, QueryRunner> = new Map<string, QueryRunner>(); export async function startDbTransaction(): Promise<string> { if (defaultDataSource !== undefined) { const queryRunner = defaultDataSource.createQueryRunner(); await queryRunner.startTransaction(); const txnId: string = crypto.randomUUID(); transactionsDb.set(txnId, queryRunner); return txnId; } else { throw new Error('Database not initialized'); } } function getDatasourceForTransaction(txnId: string | undefined): DataSource | EntityManager { if (txnId) { const qr: QueryRunner | undefined = transactionsDb.get(txnId); if (qr === undefined) { throw new Error(`Transaction not found - ${txnId}`); } else { return qr.manager; } } else { if (defaultDataSource !== undefined) return defaultDataSource; else throw new Error('No default datasource is initialized'); } } export async function commitDbTransaction(txnId: string): Promise<void> { await endTransaction(txnId, true); } export async function rollbackDbTransaction(txnId: string): Promise<void> { await endTransaction(txnId, false); } async function endTransaction(txnId: string, commit: boolean): Promise<void> { const qr: QueryRunner | undefined = transactionsDb.get(txnId); if (qr && qr.isTransactionActive) { try { if (commit) await qr.commitTransaction().catch((reason: any) => { logger.error(`failed to commit transaction ${txnId} - ${reason}`); }); else await qr.rollbackTransaction().catch((reason: any) => { logger.error(`failed to rollback transaction ${txnId} - ${reason}`); }); } finally { await qr.release(); transactionsDb.delete(txnId); } } }