UNPKG

forge-sql-orm

Version:

Drizzle ORM integration for Forge-SQL in Atlassian Forge applications.

448 lines (398 loc) 14.3 kB
import moment from "moment"; import { AnyColumn, Column, isTable, SQL, sql, StringChunk } from "drizzle-orm"; import { AnyMySqlTable, MySqlCustomColumn } from "drizzle-orm/mysql-core/index"; import { PrimaryKeyBuilder } from "drizzle-orm/mysql-core/primary-keys"; import { AnyIndexBuilder } from "drizzle-orm/mysql-core/indexes"; import { CheckBuilder } from "drizzle-orm/mysql-core/checks"; import { ForeignKeyBuilder } from "drizzle-orm/mysql-core/foreign-keys"; import { UniqueConstraintBuilder } from "drizzle-orm/mysql-core/unique-constraint"; import type { SelectedFields } from "drizzle-orm/mysql-core/query-builders/select.types"; import { MySqlTable } from "drizzle-orm/mysql-core"; import { isSQLWrapper } from "drizzle-orm/sql/sql"; /** * Interface representing table metadata information */ export interface MetadataInfo { /** The name of the table */ tableName: string; /** Record of column names and their corresponding column definitions */ columns: Record<string, AnyColumn>; /** Array of index builders */ indexes: AnyIndexBuilder[]; /** Array of check constraint builders */ checks: CheckBuilder[]; /** Array of foreign key builders */ foreignKeys: ForeignKeyBuilder[]; /** Array of primary key builders */ primaryKeys: PrimaryKeyBuilder[]; /** Array of unique constraint builders */ uniqueConstraints: UniqueConstraintBuilder[]; /** Array of all extra builders */ extras: any[]; } /** * Interface for config builder data */ interface ConfigBuilderData { value?: any; [key: string]: any; } /** * Parses a date string into a Date object using the specified format * @param value - The date string to parse * @param format - The format to use for parsing * @returns Date object */ export const parseDateTime = (value: string, format: string): Date => { let result: Date; const m = moment(value, format, true); if (!m.isValid()) { const momentDate = moment(value); if (momentDate.isValid()) { result = momentDate.toDate(); } else { result = new Date(value); } } else { result = m.toDate(); } if (isNaN(result.getTime())) { result = new Date(value); } return result; }; /** * Gets primary keys from the schema. * @template T - The type of the table schema * @param {T} table - The table schema * @returns {[string, AnyColumn][]} Array of primary key name and column pairs */ export function getPrimaryKeys<T extends AnyMySqlTable>(table: T): [string, AnyColumn][] { const { columns, primaryKeys } = getTableMetadata(table); // First try to find primary keys in columns const columnPrimaryKeys = Object.entries(columns).filter(([, column]) => column.primary) as [ string, AnyColumn, ][]; if (columnPrimaryKeys.length > 0) { return columnPrimaryKeys; } // If no primary keys found in columns, check primary key builders if (Array.isArray(primaryKeys) && primaryKeys.length > 0) { // Collect all primary key columns from all primary key builders const primaryKeyColumns = new Set<[string, AnyColumn]>(); primaryKeys.forEach((primaryKeyBuilder) => { // Get primary key columns from each builder Object.entries(columns) .filter(([, column]) => { // @ts-ignore - PrimaryKeyBuilder has internal columns property return primaryKeyBuilder.columns.includes(column); }) .forEach(([name, column]) => { primaryKeyColumns.add([name, column]); }); }); return Array.from(primaryKeyColumns); } return []; } /** * Processes foreign keys from both foreignKeysSymbol and extraSymbol * @param table - The table schema * @param foreignKeysSymbol - Symbol for foreign keys * @param extraSymbol - Symbol for extra configuration * @returns Array of foreign key builders */ function processForeignKeys( table: AnyMySqlTable, foreignKeysSymbol: symbol | undefined, extraSymbol: symbol | undefined, ): ForeignKeyBuilder[] { const foreignKeys: ForeignKeyBuilder[] = []; // Process foreign keys from foreignKeysSymbol if (foreignKeysSymbol) { // @ts-ignore const fkArray: any[] = table[foreignKeysSymbol]; if (fkArray) { fkArray.forEach((fk) => { if (fk.reference) { const item = fk.reference(fk); foreignKeys.push(item); } }); } } // Process foreign keys from extraSymbol if (extraSymbol) { // @ts-ignore const extraConfigBuilder = table[extraSymbol]; if (extraConfigBuilder && typeof extraConfigBuilder === "function") { const configBuilderData = extraConfigBuilder(table); if (configBuilderData) { const configBuilders = Array.isArray(configBuilderData) ? configBuilderData : Object.values(configBuilderData).map( (item) => (item as ConfigBuilderData).value ?? item, ); configBuilders.forEach((builder) => { if (!builder?.constructor) return; const builderName = builder.constructor.name.toLowerCase(); if (builderName.includes("foreignkeybuilder")) { foreignKeys.push(builder); } }); } } } return foreignKeys; } /** * Extracts table metadata from the schema. * @param {AnyMySqlTable} table - The table schema * @returns {MetadataInfo} Object containing table metadata */ export function getTableMetadata(table: AnyMySqlTable): MetadataInfo { const symbols = Object.getOwnPropertySymbols(table); const nameSymbol = symbols.find((s) => s.toString().includes("Name")); const columnsSymbol = symbols.find((s) => s.toString().includes("Columns")); const foreignKeysSymbol = symbols.find((s) => s.toString().includes("ForeignKeys)")); const extraSymbol = symbols.find((s) => s.toString().includes("ExtraConfigBuilder")); // Initialize builders arrays const builders = { indexes: [] as AnyIndexBuilder[], checks: [] as CheckBuilder[], foreignKeys: [] as ForeignKeyBuilder[], primaryKeys: [] as PrimaryKeyBuilder[], uniqueConstraints: [] as UniqueConstraintBuilder[], extras: [] as any[], }; // Process foreign keys builders.foreignKeys = processForeignKeys(table, foreignKeysSymbol, extraSymbol); // Process extra configuration if available if (extraSymbol) { // @ts-ignore const extraConfigBuilder = table[extraSymbol]; if (extraConfigBuilder && typeof extraConfigBuilder === "function") { const configBuilderData = extraConfigBuilder(table); if (configBuilderData) { // Convert configBuilderData to array if it's an object const configBuilders = Array.isArray(configBuilderData) ? configBuilderData : Object.values(configBuilderData).map( (item) => (item as ConfigBuilderData).value ?? item, ); // Process each builder configBuilders.forEach((builder) => { if (!builder?.constructor) return; const builderName = builder.constructor.name.toLowerCase(); // Map builder types to their corresponding arrays const builderMap = { indexbuilder: builders.indexes, checkbuilder: builders.checks, primarykeybuilder: builders.primaryKeys, uniqueconstraintbuilder: builders.uniqueConstraints, }; // Add builder to appropriate array if it matches any type for (const [type, array] of Object.entries(builderMap)) { if (builderName.includes(type)) { array.push(builder); break; } } // Always add to extras array builders.extras.push(builder); }); } } } return { tableName: nameSymbol ? (table as any)[nameSymbol] : "", columns: columnsSymbol ? ((table as any)[columnsSymbol] as Record<string, AnyColumn>) : {}, ...builders, }; } /** * Generates SQL statements to drop tables * @param tables - Array of table names * @returns Array of SQL statements for dropping tables */ export function generateDropTableStatements(tables: string[]): string[] { const dropStatements: string[] = []; tables.forEach((tableName) => { dropStatements.push(`DROP TABLE IF EXISTS \`${tableName}\`;`); dropStatements.push(`DROP SEQUENCE IF EXISTS \`${tableName}\`;`); }); return dropStatements; } type AliasColumnMap = Record<string, AnyColumn>; function mapSelectTableToAlias( table: MySqlTable, uniqPrefix: string, aliasMap: AliasColumnMap, ): any { const { columns, tableName } = getTableMetadata(table); const selectionsTableFields: Record<string, unknown> = {}; Object.keys(columns).forEach((name) => { const column = columns[name] as AnyColumn; const uniqName = `a_${uniqPrefix}_${tableName}_${column.name}`.toLowerCase(); const fieldAlias = sql.raw(uniqName); selectionsTableFields[name] = sql`${column} as \`${fieldAlias}\``; aliasMap[uniqName] = column; }); return selectionsTableFields; } function isDrizzleColumn(column: any): boolean { return column && typeof column === "object" && "table" in column; } export function mapSelectAllFieldsToAlias( selections: any, name: string, uniqName: string, fields: any, aliasMap: AliasColumnMap, ): any { if (isTable(fields)) { selections[name] = mapSelectTableToAlias(fields as MySqlTable, uniqName, aliasMap); } else if (isDrizzleColumn(fields)) { const column = fields as Column; const uniqAliasName = `a_${uniqName}_${column.name}`.toLowerCase(); let aliasName = sql.raw(uniqAliasName); selections[name] = sql`${column} as \`${aliasName}\``; aliasMap[uniqAliasName] = column; } else if (isSQLWrapper(fields)) { selections[name] = fields; } else { const innerSelections: any = {}; Object.entries(fields).forEach(([iname, ifields]) => { mapSelectAllFieldsToAlias(innerSelections, iname, `${uniqName}_${iname}`, ifields, aliasMap); }); selections[name] = innerSelections; } return selections; } export function mapSelectFieldsWithAlias<TSelection extends SelectedFields>( fields: TSelection, ): { selections: TSelection; aliasMap: AliasColumnMap } { if (!fields) { throw new Error("fields is empty"); } const aliasMap: AliasColumnMap = {}; const selections: any = {}; Object.entries(fields).forEach(([name, fields]) => { mapSelectAllFieldsToAlias(selections, name, name, fields, aliasMap); }); return { selections, aliasMap }; } function getAliasFromDrizzleAlias(value: unknown): string | undefined { const isSQL = value !== null && typeof value === "object" && isSQLWrapper(value) && "queryChunks" in value; if (isSQL) { const sql = value as SQL; const queryChunks = sql.queryChunks; if (queryChunks.length > 3) { const aliasNameChunk = queryChunks[queryChunks.length - 2]; if (isSQLWrapper(aliasNameChunk) && "queryChunks" in aliasNameChunk) { const aliasNameChunkSql = aliasNameChunk as SQL; if (aliasNameChunkSql.queryChunks?.length === 1 && aliasNameChunkSql.queryChunks[0]) { const queryChunksStringChunc = aliasNameChunkSql.queryChunks[0]; if ("value" in queryChunksStringChunc) { const values = (queryChunksStringChunc as StringChunk).value; if (values && values.length === 1) { return values[0]; } } } } } } return undefined; } function transformValue( value: unknown, alias: string, aliasMap: Record<string, AnyColumn>, ): unknown { const column = aliasMap[alias]; if (!column) return value; let customColumn = column as MySqlCustomColumn<any>; // @ts-ignore const fromDriver = customColumn?.mapFrom; if (fromDriver && value !== null && value !== undefined) { return fromDriver(value); } return value; } function transformObject( obj: Record<string, unknown>, selections: Record<string, unknown>, aliasMap: Record<string, AnyColumn>, ): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj)) { const selection = selections[key]; const alias = getAliasFromDrizzleAlias(selection); if (alias && aliasMap[alias]) { result[key] = transformValue(value, alias, aliasMap); } else if (selection && typeof selection === "object" && !isSQLWrapper(selection)) { result[key] = transformObject( value as Record<string, unknown>, selection as Record<string, unknown>, aliasMap, ); } else { result[key] = value; } } return result; } export function applyFromDriverTransform<T, TSelection>( rows: T[], selections: TSelection, aliasMap: Record<string, AnyColumn>, ): T[] { return rows.map((row) => { const transformed = transformObject( row as Record<string, unknown>, selections as Record<string, unknown>, aliasMap, ) as Record<string, unknown>; return processNullBranches(transformed) as unknown as T; }); } function processNullBranches(obj: Record<string, unknown>): Record<string, unknown> | null { if (obj === null || typeof obj !== "object") { return obj; } // Skip built-in objects like Date, Array, etc. if (obj.constructor && obj.constructor.name !== "Object") { return obj; } const result: Record<string, unknown> = {}; let allNull = true; for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) { result[key] = null; continue; } if (typeof value === "object") { const processed = processNullBranches(value as Record<string, unknown>); result[key] = processed; if (processed !== null) { allNull = false; } } else { result[key] = value; allNull = false; } } return allNull ? null : result; } export function formatLimitOffset(limitOrOffset: number): number { if (typeof limitOrOffset !== "number" || isNaN(limitOrOffset)) { throw new Error("limitOrOffset must be a valid number"); } return sql.raw(`${limitOrOffset}`) as unknown as number; } export function nextVal(sequenceName: string): number { return sql.raw(`NEXTVAL(${sequenceName})`) as unknown as number; }