import { DataTableFilter, ListDataTableContentQueryDto } from '@n8n/api-types'; import { withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource, DataSourceOptions, SelectQueryBuilder, UpdateQueryBuilder, In, ObjectLiteral, EntityManager, DeleteQueryBuilder, } from '@n8n/typeorm'; import { DataTableColumnJsType, DataTableRows, DataTableRowReturn, UnexpectedError, DataTableRowsReturn, DATA_TABLE_SYSTEM_COLUMNS, DataTableInsertRowsReturnType, DataTableInsertRowsResult, DataTableRowReturnWithState, DataTableRawRowReturn, UserError, } from 'n8n-workflow'; import { DataTableColumn } from './data-table-column.entity'; import { DataTableUserTableName } from './data-table.types'; import { escapeLikeSpecials, extractInsertedIds, extractReturningData, isValidColumnName, normalizeRows, normalizeValueForDatabase, quoteIdentifier, toSqliteGlobFromPercent, toTableName, } from './utils/sql-utils'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type QueryBuilder = SelectQueryBuilder; /** * Converts filter conditions to SQL WHERE clauses with parameters. * * LIKE / ILIKE rules: * - Only '%' is a wildcard (zero or more chars). * - All other special chars ('_', '*', '?', '[', ']') are treated literally. * - '_' and '\' are escaped in the value; SQL uses `ESCAPE '\'` so `\_` means literal underscore. * * Why the crazy backslashes: * - Postgres/SQLite/Oracle/SQL Server: `ESCAPE '\'` is written as-is. */ function getConditionAndParams( filter: DataTableFilter['filters'][number], index: number, dbType: DataSourceOptions['type'], tableReference?: string, ): [string, Record] { const paramName = `filter_${index}`; const columnRef = tableReference ? `${quoteIdentifier(tableReference, dbType)}.${quoteIdentifier(filter.columnName, dbType)}` : quoteIdentifier(filter.columnName, dbType); // Empty/not-empty treat a value as empty when it is NULL or an empty string. // Only string columns expose these conditions, so the `= ''` comparison is safe. switch (filter.condition) { case 'isEmpty': return [`(${columnRef} IS NULL OR ${columnRef} = '')`, {}]; case 'isNotEmpty': return [`(${columnRef} IS NOT NULL AND ${columnRef} != '')`, {}]; } if (filter.value === null) { switch (filter.condition) { case 'eq': return [`${columnRef} IS NULL`, {}]; case 'neq': return [`${columnRef} IS NOT NULL`, {}]; } } // For filters, we let TypeORM handle date conversion through parameterized queries. const value = filter.value; // Handle operators that map directly to SQL operators const operators: Record = { eq: '=', gt: '>', gte: '>=', lt: '<', lte: '<=', }; if (operators[filter.condition]) { return [`${columnRef} ${operators[filter.condition]} :${paramName}`, { [paramName]: value }]; } // Special handling for neq to include NULL values (only if value is not null!) if (filter.condition === 'neq') { return [`(${columnRef} != :${paramName} OR ${columnRef} IS NULL)`, { [paramName]: value }]; } switch (filter.condition) { // case-sensitive case 'like': if (['sqlite', 'sqlite-pooled'].includes(dbType)) { const globValue = toSqliteGlobFromPercent(value as string); return [`${columnRef} GLOB :${paramName}`, { [paramName]: globValue }]; } // PostgreSQL: LIKE is case-sensitive if (dbType === 'postgres') { const escapedValue = escapeLikeSpecials(value as string); return [`${columnRef} LIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }]; } // Generic fallback return [`${columnRef} LIKE :${paramName}`, { [paramName]: value }]; // case-insensitive case 'ilike': if (['sqlite', 'sqlite-pooled'].includes(dbType)) { const escapedValue = escapeLikeSpecials(value as string); return [ `UPPER(${columnRef}) LIKE UPPER(:${paramName}) ESCAPE '\\'`, { [paramName]: escapedValue }, ]; } if (dbType === 'postgres') { const escapedValue = escapeLikeSpecials(value as string); return [`${columnRef} ILIKE :${paramName} ESCAPE '\\'`, { [paramName]: escapedValue }]; } return [`UPPER(${columnRef}) LIKE UPPER(:${paramName})`, { [paramName]: value }]; } // This should never happen as all valid conditions are handled above throw new Error(`Unsupported filter condition: ${filter.condition}`); } @Service() export class DataTableRowsRepository { constructor(private dataSource: DataSource) {} async insertRowsBulk( table: DataTableUserTableName, rows: DataTableRows, columns: DataTableColumn[], trx?: EntityManager, ) { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { let insertedRows = 0; // Special case: no columns, insert each row individually if (columns.length === 0) { for (const row of rows) { const query = em.createQueryBuilder().insert().into(table).values(row); await query.execute(); insertedRows++; } return { success: true, insertedRows } as const; } // DB systems have different maximum parameters per query // with old sqlite versions having the lowest in 999 parameters // In practice 20000 works here, but performance didn't meaningfully change // so this should be a safe limit const batchSize = 800; const batches = Math.max(1, Math.ceil((columns.length * rows.length) / batchSize)); const rowsPerBatch = Math.ceil(rows.length / batches); const columnNames = columns.map((x) => x.name); const dbType = this.dataSource.options.type; for (let i = 0; i < batches; ++i) { const start = i * rowsPerBatch; const endExclusive = Math.min(rows.length, (i + 1) * rowsPerBatch); if (endExclusive <= start) break; const completeRows = new Array(endExclusive - start); for (let j = start; j < endExclusive; ++j) { const insertArray: DataTableColumnJsType[] = []; for (let h = 0; h < columnNames.length; ++h) { const column = columns[h]; // Fill missing columns with null values to support partial data insertion const value = rows[j][column.name] ?? null; insertArray[h] = normalizeValueForDatabase(value, column.type, dbType); } completeRows[j - start] = insertArray; } const query = em .createQueryBuilder() .insert() .into(table, columnNames) .values(completeRows); await query.execute(); insertedRows += completeRows.length; } return { success: true, insertedRows } as const; }); } async insertRows( dataTableId: string, rows: DataTableRows, columns: DataTableColumn[], returnType: T, trx?: EntityManager, ): Promise>; async insertRows( dataTableId: string, rows: DataTableRows, columns: DataTableColumn[], returnType: T, trx?: EntityManager, ): Promise { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { const inserted: Array> = []; const dbType = this.dataSource.options.type; const useReturning = dbType === 'postgres'; const table = toTableName(dataTableId); const escapedColumns = columns.map((c) => this.dataSource.driver.escape(c.name)); const escapedSystemColumns = DATA_TABLE_SYSTEM_COLUMNS.map((x) => this.dataSource.driver.escape(x), ); const selectColumns = [...escapedSystemColumns, ...escapedColumns]; if (returnType === 'count') { return await this.insertRowsBulk(table, rows, columns, em); } // We insert one by one as the default behavior of returning the last inserted ID // is consistent, whereas getting all inserted IDs when inserting multiple values is // surprisingly awkward without Entities, e.g. `RETURNING id` explicitly does not aggregate // and the `identifiers` array output of `execute()` is empty for (const row of rows) { // Fill missing columns with null values to support partial data insertion const completeRow = { ...row }; for (const column of columns) { if (!(column.name in completeRow)) { completeRow[column.name] = null; } completeRow[column.name] = normalizeValueForDatabase( completeRow[column.name], column.type, dbType, ); } const query = em.createQueryBuilder().insert().into(table).values(completeRow); if (useReturning) { query.returning(returnType === 'all' ? selectColumns.join(',') : 'id'); } const result = await query.execute(); if (useReturning) { const returned = returnType === 'all' ? normalizeRows(extractReturningData(result.raw), columns) : extractInsertedIds(result.raw, dbType).map((id) => ({ id })); inserted.push.apply(inserted, returned); continue; } // Engines without RETURNING support const ids = extractInsertedIds(result.raw, dbType); if (ids.length === 0) { throw new UnexpectedError("Couldn't find the inserted row ID"); } if (returnType === 'id') { inserted.push(...ids.map((id) => ({ id }))); continue; } const insertedRows = await this.getManyByIds(dataTableId, ids, columns, em); inserted.push(...insertedRows); } return inserted; }); } async updateRows( dataTableId: string, data: Record, filter: DataTableFilter, columns: DataTableColumn[], returnData?: T, trx?: EntityManager, ): Promise; async updateRows( dataTableId: string, data: Record, filter: DataTableFilter, columns: DataTableColumn[], returnData: boolean = false, trx?: EntityManager, ) { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { const dbType = this.dataSource.options.type; const useReturning = dbType === 'postgres'; const table = toTableName(dataTableId); const escapedColumns = columns.map((c) => this.dataSource.driver.escape(c.name)); const escapedSystemColumns = DATA_TABLE_SYSTEM_COLUMNS.map((x) => this.dataSource.driver.escape(x), ); const selectColumns = [...escapedSystemColumns, ...escapedColumns]; const setData = this.prepareUpdateData(data, columns, dbType); let affectedRows: Array> = []; if (!useReturning && returnData) { // Only Postgres supports RETURNING statement on updates (with our typeorm), // on other engines we must query the list of updates rows later by ID affectedRows = await this.getAffectedRowsForUpdate(dataTableId, filter, columns, true, em); } setData.updatedAt = normalizeValueForDatabase(new Date(), 'date', dbType); const query = em.createQueryBuilder().update(table); // Some DBs (like SQLite) don't allow using table aliases as column prefixes in UPDATE statements this.applyFilters(query, filter, undefined); query.set(setData); if (useReturning && returnData) { query.returning(selectColumns.join(',')); } const result = await query.execute(); if (!returnData) { return true; } if (useReturning) { return normalizeRows(extractReturningData(result.raw), columns); } const ids = affectedRows.map((row) => row.id); return await this.getManyByIds(dataTableId, ids, columns, em); }); } async dryRunUpdateRows( dataTableId: string, data: Record, filter: DataTableFilter, columns: DataTableColumn[], trx?: EntityManager, ): Promise { const dbType = this.dataSource.options.type; const beforeRows = await this.getAffectedRowsForUpdate( dataTableId, filter, columns, false, trx, ); const columnUpdates = this.prepareUpdateData(data, columns, dbType); return beforeRows.flatMap((original) => { const updated = { ...original, ...columnUpdates, updatedAt: new Date() }; return this.toDryRunRows(original, updated); }); } async dryRunUpsertRow( dataTableId: string, data: Record, filter: DataTableFilter, columns: DataTableColumn[], trx?: EntityManager, ): Promise { const updateResult = await this.dryRunUpdateRows(dataTableId, data, filter, columns, trx); if (updateResult.length > 0) { return updateResult; } // No rows were updated, simulate insert const dbType = this.dataSource.options.type; const now = new Date(); const preparedData = this.prepareUpdateData(data, columns, dbType); const insertedRow: DataTableRowReturn = { id: 0, // Placeholder ID for dry run createdAt: now, updatedAt: now, ...preparedData, }; return this.toDryRunRows(null, insertedRow); } /** * Deletes rows from the data table. * Note: `dryRun` overrides `returnData` and always returns the affected rows without deleting them. * * @param dataTableId - The ID of the data table. * @param columns - The columns of the data table. * @param filter - The filter to select rows for deletion. * @param returnData - Whether to return the deleted rows. * @param dryRun - If true, simulates deletion and returns affected rows without deleting. */ async deleteRows( dataTableId: string, columns: DataTableColumn[], filter: DataTableFilter | undefined, returnData: boolean = false, dryRun: boolean = false, trx?: EntityManager, ) { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { const dbType = this.dataSource.options.type; const useReturning = !dryRun && dbType === 'postgres'; const shouldReturnData = returnData || dryRun; const table = toTableName(dataTableId); if (!shouldReturnData) { // Just delete and return true const query = em.createQueryBuilder().delete().from(table, 'dataTable'); if (filter) { this.applyFilters(query, filter, undefined); } await query.execute(); return true; } let affectedRows: DataTableRowReturn[] = []; if (!useReturning) { const selectQuery = em.createQueryBuilder().select('*').from(table, 'dataTable'); if (filter) { this.applyFilters(selectQuery, filter, 'dataTable'); } const rawRows = await selectQuery.getRawMany(); affectedRows = normalizeRows(rawRows, columns); } // Skip deletion for dry run if (dryRun) { return affectedRows.flatMap((row) => this.toDryRunRows(row, null)); } const deleteQuery = em.createQueryBuilder().delete().from(table, 'dataTable'); if (useReturning) { const escapedColumns = columns.map((c) => this.dataSource.driver.escape(c.name)); const escapedSystemColumns = DATA_TABLE_SYSTEM_COLUMNS.map((x) => this.dataSource.driver.escape(x), ); const selectColumns = [...escapedSystemColumns, ...escapedColumns]; deleteQuery.returning(selectColumns.join(',')); } if (filter) { this.applyFilters(deleteQuery, filter, undefined); } const result = await deleteQuery.execute(); if (useReturning) { affectedRows = normalizeRows(extractReturningData(result.raw), columns); } return affectedRows; }); } async clearRows(dataTableId: string, trx?: EntityManager): Promise<{ deletedCount: number }> { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { const table = toTableName(dataTableId); const result = await em.createQueryBuilder().delete().from(table).execute(); return { deletedCount: result.affected ?? 0 }; }); } private async getAffectedRowsForUpdate( dataTableId: string, filter: DataTableFilter, columns: DataTableColumn[], idsOnly: T, trx?: EntityManager, ): Promise> : DataTableRowReturn[]> { // oxlint-disable-next-line typescript/no-deprecated return await withTransaction(this.dataSource.manager, trx, async (em) => { const table = toTableName(dataTableId); const selectColumns = idsOnly ? 'id' : '*'; const selectQuery = em.createQueryBuilder().select(selectColumns).from(table, 'dataTable'); this.applyFilters(selectQuery, filter, 'dataTable'); const rawRows: DataTableRowsReturn = await selectQuery.getRawMany(); if (idsOnly) { return rawRows; } return normalizeRows(rawRows, columns); }); } private prepareUpdateData( data: Record, columns: DataTableColumn[], dbType: DataSourceOptions['type'], ): Record { const setData = { ...data }; for (const column of columns) { if (column.name in setData) { setData[column.name] = normalizeValueForDatabase(setData[column.name], column.type, dbType); } } return setData; } private toDryRunRows( beforeState: DataTableRowReturn | null, afterState: DataTableRowReturn | null, ): DataTableRowReturnWithState[] { if (beforeState === null && afterState === null) { throw new Error('Both before and after rows cannot be null'); } if (beforeState && afterState) { return [ { ...beforeState, dryRunState: 'before' }, { ...afterState, dryRunState: 'after' }, ]; } // If one of the states is null, create a template row with nulls for missing values const template = (beforeState ?? afterState)!; const nullRow = { id: null, createdAt: null, updatedAt: null, ...Object.fromEntries(Object.keys(template).map((key) => [key, null])), }; const before = beforeState ?? nullRow; const after = afterState ?? nullRow; return [ { ...before, dryRunState: 'before' }, { ...after, dryRunState: 'after' }, ]; } async getManyAndCount( dataTableId: string, dto: ListDataTableContentQueryDto, columns: DataTableColumn[], trx?: EntityManager, ) { const em = trx ?? this.dataSource.manager; const [countQuery, query] = this.getManyQuery(dataTableId, dto, columns, em); const data: DataTableRowsReturn = await query.select('*').getRawMany(); const countResult = await countQuery.select('COUNT(*) as count').getRawOne<{ count: number | string | null; }>(); const count = typeof countResult?.count === 'number' ? countResult.count : Number(countResult?.count) || 0; return { count: count ?? -1, data }; } async getManyByIds( dataTableId: string, ids: number[], columns: DataTableColumn[], trx?: EntityManager, ) { const em = trx ?? this.dataSource.manager; const table = toTableName(dataTableId); const escapedColumns = columns.map((c) => this.dataSource.driver.escape(c.name)); const escapedSystemColumns = DATA_TABLE_SYSTEM_COLUMNS.map((x) => this.dataSource.driver.escape(x), ); const selectColumns = [...escapedSystemColumns, ...escapedColumns]; if (ids.length === 0) { return []; } const rows = await em .createQueryBuilder() .select(selectColumns) .from(table, 'dataTable') .where({ id: In(ids) }) .getRawMany(); return normalizeRows(rows, columns); } private getManyQuery( dataTableId: string, dto: ListDataTableContentQueryDto, columns: DataTableColumn[], em: EntityManager, ): [QueryBuilder, QueryBuilder] { const query = em.createQueryBuilder(); const tableReference = 'dataTable'; query.from(toTableName(dataTableId), tableReference); if (dto.filter) { this.applyFilters(query, dto.filter, tableReference); } if (dto.search && dto.search.trim().length > 0) { this.applySearch(query, dto.search, tableReference, columns); } const countQuery = query.clone().select('COUNT(*)'); this.applySorting(query, dto); this.applyPagination(query, dto); return [countQuery, query]; } private applySearch( query: QueryBuilder, rawSearch: string, tableReference: string, columns: DataTableColumn[], ) { const dbType = this.dataSource.options.type; const searchTerm = rawSearch.includes('%') ? rawSearch : `%${rawSearch}%`; const isSqlite = ['sqlite', 'sqlite-pooled'].includes(dbType); const isPg = dbType === 'postgres'; const allColumnNames: string[] = columns.map((c) => c.name); if (allColumnNames.length === 0) return; const tableRefQuoted = quoteIdentifier(tableReference, dbType); const conditions: string[] = []; for (const col of allColumnNames) { const colRef = `${tableRefQuoted}.${quoteIdentifier(col, dbType)}`; if (isSqlite) { conditions.push(`UPPER(CAST(${colRef} AS TEXT)) LIKE UPPER(:search) ESCAPE '\\'`); continue; } if (isPg) { conditions.push(`CAST(${colRef} AS TEXT) ILIKE :search ESCAPE '\\'`); continue; } conditions.push(`UPPER(CAST(${colRef} AS TEXT)) LIKE UPPER(:search)`); } if (conditions.length === 0) return; const whereClause = `(${conditions.join(' OR ')})`; query.andWhere(whereClause, { search: escapeLikeSpecials(searchTerm) }); } private applyFilters( query: SelectQueryBuilder | UpdateQueryBuilder | DeleteQueryBuilder, filter: DataTableFilter, tableReference?: string, ): void { const filters = filter.filters ?? []; const filterType = filter.type ?? 'and'; const dbType = this.dataSource.options.type; const conditionsAndParams = filters.map((filter, i) => getConditionAndParams(filter, i, dbType, tableReference), ); if (conditionsAndParams.length === 1) { // Always use AND for a single filter const [condition, params] = conditionsAndParams[0]; query.andWhere(condition, params); } else { for (const [condition, params] of conditionsAndParams) { if (filterType === 'or') { query.orWhere(condition, params); } else { query.andWhere(condition, params); } } } } private applySorting(query: QueryBuilder, dto: ListDataTableContentQueryDto): void { if (dto.sortBy) { const [field, order] = dto.sortBy; this.applySortingByField(query, field, order); } // Always append the unique `id` as a final tiebreaker so skip/take pagination // returns a stable order across pages (avoids duplicate/missing rows). if (!dto.sortBy || dto.sortBy[0] !== 'id') { const dbType = this.dataSource.options.type; const quotedId = `${quoteIdentifier('dataTable', dbType)}.${quoteIdentifier('id', dbType)}`; query.addOrderBy(quotedId, 'ASC'); } } private applySortingByField(query: QueryBuilder, field: string, direction: 'DESC' | 'ASC'): void { const dbType = this.dataSource.options.type; if (!isValidColumnName(field)) throw new UserError('Incorrect column format'); const quotedField = `${quoteIdentifier('dataTable', dbType)}.${quoteIdentifier(field, dbType)}`; query.orderBy(quotedField, direction); } private applyPagination(query: QueryBuilder, dto: ListDataTableContentQueryDto): void { query.skip(dto.skip ?? 0); if (dto.take) query.take(dto.take); } }