generate raw sql because postgres does not support prepared statements in DO blocks

This commit is contained in:
Hongbo Wu 2023-12-28 16:02:39 +08:00
parent 1079a19cd9
commit 55518139fb
3 changed files with 129 additions and 42 deletions

View file

@ -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 <T>(
export const getRepository = <T>(entity: EntityTarget<T>) => {
return appDataSource.getRepository(entity)
}
export const queryBuilderToRawSql = <T>(q: QueryBuilder<T>): 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, string | number | boolean>
): 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
}

View file

@ -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<string, any> = {}
let values: Record<string, string | number> = {}
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)
})
}

View file

@ -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: '<p>test</p>',
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: '<p>test</p>',
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: '<p>test</p>',
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),