diff --git a/packages/api/src/repository/index.ts b/packages/api/src/repository/index.ts index 9e1dd187d..9d6ce825a 100644 --- a/packages/api/src/repository/index.ts +++ b/packages/api/src/repository/index.ts @@ -1,5 +1,5 @@ import * as httpContext from 'express-http-context2' -import { EntityManager, EntityTarget, Repository } from 'typeorm' +import { EntityManager, EntityTarget, QueryBuilder, Repository } from 'typeorm' import { appDataSource } from '../data_source' import { Claims } from '../resolvers/types' import { SetClaimsRole } from '../utils/dictionary' @@ -45,3 +45,59 @@ export const authTrx = async ( export const getRepository = (entity: EntityTarget) => { return appDataSource.getRepository(entity) } + +export const queryBuilderToRawSql = (q: QueryBuilder): string => { + const queryAndParams = q.getQueryAndParameters() + let sql = queryAndParams[0] + const params = queryAndParams[1] + + params.forEach((value, index) => { + if (typeof value === 'string') { + sql = sql.replace(`$${index + 1}`, `'${value}'`) + } else if (typeof value === 'object') { + if (Array.isArray(value)) { + sql = sql.replace( + `$${index + 1}`, + value + .map((element) => { + if (typeof element === 'string') { + return `'${element}'` + } + + if (typeof element === 'number' || typeof element === 'boolean') { + return element.toString() + } + }) + .join(',') + ) + } else if (value instanceof Date) { + sql = sql.replace(`$${index + 1}`, `'${value.toISOString()}'`) + } + } else if (typeof value === 'number' || typeof value === 'boolean') { + sql = sql.replace(`$${index + 1}`, value.toString()) + } + }) + + return sql +} + +export const valuesToRawSql = ( + values: Record +): string => { + let sql = '' + + Object.keys(values).forEach((key, index) => { + const value = values[key] + if (typeof value === 'string') { + sql += `${key} = '${value}'` + } else { + sql += `${key} = ${value.toString()}` + } + + if (index < Object.keys(values).length - 1) { + sql += ', ' + } + }) + + return sql +} diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index e97f27ba8..95a79a663 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -8,7 +8,12 @@ import { Label } from '../entity/label' import { LibraryItem, LibraryItemState } from '../entity/library_item' import { BulkActionType, InputMaybe, SortParams } from '../generated/graphql' import { createPubSubClient, EntityType } from '../pubsub' -import { authTrx, getColumns } from '../repository' +import { + authTrx, + getColumns, + queryBuilderToRawSql, + valuesToRawSql, +} from '../repository' import { libraryItemRepository } from '../repository/library_item' import { wordsCount } from '../utils/helpers' import { parseSearchQuery } from '../utils/search' @@ -901,20 +906,21 @@ export const batchUpdateLibraryItems = async ( return 'folder' in args } + const now = new Date().toISOString() // build the script - let values: Record = {} + let values: Record = {} let addLabels = false switch (action) { case BulkActionType.Archive: values = { - archivedAt: new Date(), + archived_at: now, state: LibraryItemState.Archived, } break case BulkActionType.Delete: values = { state: LibraryItemState.Deleted, - deletedAt: new Date(), + deleted_at: now, } break case BulkActionType.AddLabels: @@ -922,9 +928,9 @@ export const batchUpdateLibraryItems = async ( break case BulkActionType.MarkAsRead: values = { - readAt: new Date(), - readingProgressTopPercent: 100, - readingProgressBottomPercent: 100, + read_at: now, + reading_progress_top_percent: 100, + reading_progress_bottom_percent: 100, } break case BulkActionType.MoveToFolder: @@ -934,7 +940,7 @@ export const batchUpdateLibraryItems = async ( values = { folder: args.folder, - savedAt: new Date(), + saved_at: now, } break @@ -984,19 +990,20 @@ export const batchUpdateLibraryItems = async ( return tx.getRepository(EntityLabel).save(labelsToAdd) } - const countSql = queryBuilder.select('COUNT(1) INTO total_rows').getSql() - const [subQuery, params] = queryBuilder.select('id').getQueryAndParameters() - const valuesSql = Object.keys(values) - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - .map((key) => `${key} = ${values[key]}`) - .join(', ') + // generate raw sql because postgres doesn't support prepared statements in DO blocks + const countSql = queryBuilderToRawSql( + queryBuilder.select('COUNT(1) INTO total_rows') + ) + const subQuery = queryBuilderToRawSql(queryBuilder.select('id')) + const valuesSql = valuesToRawSql(values) + const batchSize = 100 const sql = ` -- Set batch size DO $$ DECLARE - batch_size INT := 100; - total_rows INT := 1000; + batch_size INT := ${batchSize}; + total_rows INT; num_batches INT; current_offset INT; BEGIN @@ -1025,7 +1032,7 @@ export const batchUpdateLibraryItems = async ( END $$ ` - return tx.query(sql, params) + return tx.query(sql) }) } diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index f2421e38a..3bc0bf443 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -2107,31 +2107,31 @@ describe('Article API', () => { } ` - before(async () => { - // Create some test items - for (let i = 0; i < 5; i++) { - await createLibraryItem( - { - user, - itemType: i == 0 ? PageType.Article : PageType.File, - title: 'test item', - readableContent: '

test

', - slug: '', - state: - i == 0 ? LibraryItemState.Failed : LibraryItemState.Succeeded, - originalUrl: `https://blog.omnivore.app/p/bulk-action-${i}`, - }, - user.id - ) - } - }) - - after(async () => { - // Delete all items - await deleteLibraryItemsByUserId(user.id) - }) - context('when action is MarkAsRead and query is in:unread', () => { + before(async () => { + // Create some test items + for (let i = 0; i < 5; i++) { + await createLibraryItem( + { + user, + itemType: i == 0 ? PageType.Article : PageType.File, + title: 'test item', + readableContent: '

test

', + slug: '', + state: + i == 0 ? LibraryItemState.Failed : LibraryItemState.Succeeded, + originalUrl: `https://blog.omnivore.app/p/bulk-action-${i}`, + }, + user.id + ) + } + }) + + after(async () => { + // Delete all items + await deleteLibraryItemsByUserId(user.id) + }) + it('marks unread items as read', async () => { const res = await graphqlRequest( bulkActionQuery(BulkActionType.MarkAsRead, 'is:unread'), @@ -2199,6 +2199,30 @@ describe('Article API', () => { ) context('when action is Delete', () => { + before(async () => { + // Create some test items + for (let i = 0; i < 5; i++) { + await createLibraryItem( + { + user, + itemType: i == 0 ? PageType.Article : PageType.File, + title: 'test item', + readableContent: '

test

', + slug: '', + state: + i == 0 ? LibraryItemState.Failed : LibraryItemState.Succeeded, + originalUrl: `https://blog.omnivore.app/p/bulk-action-${i}`, + }, + user.id + ) + } + }) + + after(async () => { + // Delete all items + await deleteLibraryItemsByUserId(user.id) + }) + it('deletes all items', async () => { const res = await graphqlRequest( bulkActionQuery(BulkActionType.Delete),