Files
DianZhanDemo/server/lib/sql-query-builder.ts

740 lines
23 KiB
TypeScript
Raw Permalink Normal View History

2025-12-11 02:09:07 +08:00
/**
* SQL查询构建与执行工具
* SQL Query Builder & Executor
*
*
* 1) SQL字符串构建
* 2) Prisma
*/
import type { PrismaClient } from '@prisma/client'
import { Prisma } from '@prisma/client'
import type { AdvancedQueryOptions } from '@nuxt4crud/shared/types'
export interface QueryBuilderOptions {
table: string
select?: string[]
where?: Record<string, any>
orderBy?: string
orderDirection?: 'asc' | 'desc'
limit?: number
offset?: number
}
/**
* SQL查询构建器类
* SQL Query Builder Class
*/
// 仅用于生成基础 SQL 预览字符串的构建器(不直接执行)
export class SqlQueryBuilder {
private options: QueryBuilderOptions
constructor(options: QueryBuilderOptions) {
this.options = options
}
/**
* SELECT查询
* Build SELECT query
*/
buildSelectQuery(): { sql: string; params: any[] } {
const params: any[] = []
let sql = 'SELECT '
// SELECT 子句
if (this.options.select && this.options.select.length > 0) {
sql += this.options.select.join(', ')
} else {
sql += '*'
}
// FROM 子句
sql += ` FROM ${this.escapeIdentifier(this.options.table)}`
// WHERE 子句
const whereClause = this.buildWhereClause(params)
if (whereClause) {
sql += ` WHERE ${whereClause}`
}
// ORDER BY 子句
if (this.options.orderBy) {
sql += ` ORDER BY ${this.escapeIdentifier(this.options.orderBy)} ${this.options.orderDirection || 'ASC'}`
}
// LIMIT 子句
if (this.options.limit) {
sql += ` LIMIT ${this.options.limit}`
}
// OFFSET 子句
if (this.options.offset) {
sql += ` OFFSET ${this.options.offset}`
}
return { sql, params }
}
/**
* COUNT查询
* Build COUNT query
*/
buildCountQuery(): { sql: string; params: any[] } {
const params: any[] = []
let sql = `SELECT COUNT(*) as count FROM ${this.escapeIdentifier(this.options.table)}`
// WHERE 子句
const whereClause = this.buildWhereClause(params)
if (whereClause) {
sql += ` WHERE ${whereClause}`
}
return { sql, params }
}
/**
* WHERE子句
* Build WHERE clause
*/
private buildWhereClause(params: any[]): string {
if (!this.options.where || Object.keys(this.options.where).length === 0) {
return ''
}
const conditions: string[] = []
Object.entries(this.options.where).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
conditions.push(`${this.escapeIdentifier(key)} = ?`)
params.push(value)
}
})
return conditions.join(' AND ')
}
/**
* SQL注入
* Escape identifier (prevent SQL injection)
*/
private escapeIdentifier(identifier: string): string {
// 简单的标识符转义,移除特殊字符
return identifier.replace(/[^a-zA-Z0-9_]/g, '')
}
/**
*
* Get paginated query
*/
buildPaginatedQueries(): {
selectQuery: { sql: string; params: any[] }
countQuery: { sql: string; params: any[] }
} {
return {
selectQuery: this.buildSelectQuery(),
countQuery: this.buildCountQuery(),
}
}
}
/**
* Prisma
* Prisma-driven query builder and executor
*/
export class PrismaSqlQueryBuilder {
constructor(private prisma: PrismaClient) {}
/**
* postgres/mysql/sqlite
* Get current database dialect from DATABASE_URL
*/
private getDialect(): 'postgres' | 'mysql' | 'sqlite' {
const url = process.env.DATABASE_URL || 'file:./dev.db'
if (url.startsWith('postgres') || url.includes('postgresql')) return 'postgres'
if (url.startsWith('mysql')) return 'mysql'
// Prisma sqlite 通常是 file:./xxx.db
return 'sqlite'
}
/**
*
* Get table column names using database system views
*/
async getTableFields(tableName: string): Promise<string[]> {
const dialect = this.getDialect()
try {
if (dialect === 'postgres') {
const rows: Array<{ column_name: string }> = await (this.prisma.$queryRawUnsafe as any)(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}' ORDER BY ordinal_position;`
)
return rows.map(r => r.column_name)
}
if (dialect === 'mysql') {
const rows: Array<{ Field: string }> = await (this.prisma.$queryRawUnsafe as any)(
`SHOW COLUMNS FROM \`${tableName}\`;`
)
return rows.map(r => r.Field)
}
// sqlite
const rows: Array<{ name: string }> = await (this.prisma.$queryRawUnsafe as any)(
`PRAGMA table_info('${tableName}');`
)
return rows.map(r => r.name)
} catch (e) {
console.warn('getTableFields failed, falling back to defaults:', e)
// 尝试返回常见字段集合作为降级方案
if (tableName === 'users') {
return ['id', 'name', 'email', 'age', 'createdAt', 'updatedAt']
}
return ['id', 'createdAt', 'updatedAt']
}
}
/**
*
* Validate AdvancedQueryOptions against available fields
*/
validateQueryOptions(options: AdvancedQueryOptions, availableFields: string[]): string[] {
const errors: string[] = []
const fieldSet = new Set(availableFields)
// where 条件校验
if (Array.isArray(options.where)) {
for (const cond of options.where) {
if (!cond || !cond.field) {
errors.push('存在缺少字段的条件')
continue
}
if (!fieldSet.has(cond.field)) {
errors.push(`字段不存在: ${cond.field}`)
}
if (!cond.operator) {
errors.push(`字段 ${cond.field} 缺少操作符`)
}
const nullOps = new Set(['is_null', 'is_not_null'])
const multiOps = new Set(['in', 'not_in', 'between', 'date_between'])
if (!nullOps.has(cond.operator)) {
if (multiOps.has(cond.operator)) {
if (!Array.isArray(cond.value) || cond.value.length === 0) {
errors.push(`字段 ${cond.field} 的操作符 ${cond.operator} 需要数组值`)
}
} else if (cond.value === undefined || cond.value === null || cond.value === '') {
errors.push(`字段 ${cond.field} 的操作符 ${cond.operator} 需要提供值`)
}
}
}
}
// 排序校验
if (Array.isArray(options.orderBy)) {
for (const s of options.orderBy) {
if (!s || !s.field) {
errors.push('存在缺少字段的排序项')
continue
}
if (!fieldSet.has(s.field)) errors.push(`排序字段不存在: ${s.field}`)
const dir = String(s.direction || '').toUpperCase()
if (dir !== 'ASC' && dir !== 'DESC') errors.push(`排序方向非法: ${s.direction}`)
}
}
// 分页校验
const page = Number(options.pagination?.page || 1)
const limit = Number(options.pagination?.limit || 20)
if (!Number.isFinite(page) || page < 1) errors.push('分页页码必须为正整数')
if (!Number.isFinite(limit) || limit < 1 || limit > 1000)
errors.push('分页数量必须在1-1000之间')
return errors
}
/**
* Prisma where
* Map one condition to Prisma where fragment
*/
private mapConditionToPrisma(cond: any): any {
const { field, operator, value } = cond || {}
// 如果值为null、undefined或空字符串除了isNull/isNotNull操作符返回空对象
if (
(value === null || value === undefined || value === '') &&
operator !== 'is_null' &&
operator !== 'is_not_null'
) {
return {}
}
switch (operator) {
case 'equals':
case 'eq': // 支持简写形式
return { [field]: value }
case 'not_equals':
case 'ne': // 支持简写形式
return { [field]: { not: value } }
case 'contains':
case 'like': // 支持简写形式
// 确保值不为空字符串,否则会导致不匹配任何记录
if (String(value) === '') return {}
return { [field]: { contains: String(value) } }
case 'not_contains':
case 'notLike': // 支持简写形式
// 确保值不为空字符串,否则会导致不匹配任何记录
if (String(value) === '') return {}
return { [field]: { not: { contains: String(value) } } }
case 'starts_with':
// 确保值不为空字符串,否则会导致不匹配任何记录
if (String(value) === '') return {}
return { [field]: { startsWith: String(value) } }
case 'ends_with':
// 确保值不为空字符串,否则会导致不匹配任何记录
if (String(value) === '') return {}
return { [field]: { endsWith: String(value) } }
case 'gt':
case 'greater_than':
return { [field]: { gt: value } }
case 'gte':
case 'greater_than_or_equal':
return { [field]: { gte: value } }
case 'lt':
case 'less_than':
return { [field]: { lt: value } }
case 'lte':
case 'less_than_or_equal':
return { [field]: { lte: value } }
case 'in': {
const arr = Array.isArray(value)
? value
: String(value)
.split(',')
.map(v => v.trim())
// 确保数组不为空
if (arr.length === 0 || (arr.length === 1 && arr[0] === '')) return {}
return { [field]: { in: arr } }
}
case 'not_in': {
const arr = Array.isArray(value)
? value
: String(value)
.split(',')
.map(v => v.trim())
// 确保数组不为空
if (arr.length === 0 || (arr.length === 1 && arr[0] === '')) return {}
return { [field]: { notIn: arr } }
}
case 'is_null':
return { [field]: null }
case 'is_not_null':
return { [field]: { not: null } }
case 'between': {
const [from, to] = Array.isArray(value) ? value : []
// 确保from和to都有效
if (from === undefined || to === undefined || from === '' || to === '') return {}
return { [field]: { gte: from, lte: to } }
}
case 'date_between': {
const [from, to] = Array.isArray(value) ? value : []
// 确保from和to都有效
if (from === undefined || to === undefined || from === '' || to === '') return {}
return { [field]: { gte: new Date(from), lte: new Date(to) } }
}
default:
return {}
}
}
/**
* Prisma where SQL
* Build Prisma where clause and a basic SQL preview from options
*/
private buildWhereFromOptions(
tableName: string,
options: AdvancedQueryOptions,
searchFields: string[]
): { where: any; sqlPreview: string } {
const where: any = {}
const andConditions: any[] = []
const orConditions: any[] = []
const sqlPreviewParts: string[] = []
// 搜索条件
const search = options.search?.trim()
if (search && searchFields.length > 0) {
const searchOr = searchFields.map(f => ({ [f]: { contains: search } }))
// 默认与其它条件 AND 组合
andConditions.push({ OR: searchOr })
sqlPreviewParts.push(`(${searchFields.map(f => `${f} LIKE '%${search}%'`).join(' OR ')})`)
}
// where 条件
if (Array.isArray(options.where)) {
for (const cond of options.where) {
const { field, operator, value } = cond
// 特殊处理posts表的title字段因为在Prisma模型中title是必填字段String
if (tableName === 'posts' && field === 'title') {
if (operator === 'isNull' || operator === 'is_null') {
// posts表的title字段不可能为null所以如果有这个条件直接返回空结果
return { where: { id: -1 }, sqlPreview: 'WHERE title IS NULL' }
} else if (operator === 'isNotNull' || operator === 'is_not_null') {
// posts表的title字段总是不为null所以这个条件可以忽略
continue
}
}
const frag = this.mapConditionToPrisma(cond)
if (!frag || Object.keys(frag).length === 0) continue
// 普通条件处理
if (String(cond.logicalOperator || 'AND').toUpperCase() === 'OR') {
orConditions.push(frag)
} else {
andConditions.push(frag)
}
// 生成SQL预览
let sqlPart = ''
switch (operator) {
case 'equals':
sqlPart = `${field} = '${value}'`
break
case 'not_equals':
sqlPart = `${field} <> '${value}'`
break
case 'contains':
sqlPart = `${field} LIKE '%${value}%'`
break
case 'not_contains':
sqlPart = `${field} NOT LIKE '%${value}%'`
break
case 'starts_with':
sqlPart = `${field} LIKE '${value}%'`
break
case 'ends_with':
sqlPart = `${field} LIKE '%${value}'`
break
case 'gt':
sqlPart = `${field} > ${value}`
break
case 'gte':
sqlPart = `${field} >= ${value}`
break
case 'lt':
sqlPart = `${field} < ${value}`
break
case 'lte':
sqlPart = `${field} <= ${value}`
break
case 'in':
sqlPart = `${field} IN (${Array.isArray(value) ? value.map(v => `'${v}'`).join(', ') : `'${value}'`})`
break
case 'not_in':
sqlPart = `${field} NOT IN (${Array.isArray(value) ? value.map(v => `'${v}'`).join(', ') : `'${value}'`})`
break
case 'is_null':
sqlPart = `${field} IS NULL`
break
case 'is_not_null':
sqlPart = `${field} IS NOT NULL`
break
case 'between':
const [from, to] = Array.isArray(value) ? value : []
sqlPart = `${field} BETWEEN ${from} AND ${to}`
break
}
if (sqlPart) {
if (sqlPreviewParts.length > 0) {
sqlPreviewParts.push(String(cond.logicalOperator || 'AND').toUpperCase())
}
sqlPreviewParts.push(sqlPart)
}
}
}
let finalConditions: any[] = []
// 处理搜索条件
if (search && searchFields.length > 0) {
const searchOr = searchFields.map(f => ({ [f]: { contains: search } }))
finalConditions.push({ OR: searchOr })
}
// 正确处理逻辑操作符序列
// 前端发送的条件数组中每个条件除了第一个都有一个logicalOperator
// 表示该条件与前一个条件的连接方式
if (Array.isArray(options.where) && options.where.length > 0) {
// 第一个条件
let currentCondition = this.mapConditionToPrisma(options.where[0])
// 如果只有一个条件,直接使用它
if (options.where.length === 1) {
if (currentCondition && Object.keys(currentCondition).length > 0) {
finalConditions.push(currentCondition)
}
} else {
// 处理多个条件
for (let i = 1; i < options.where.length; i++) {
const nextCondition = this.mapConditionToPrisma(options.where[i])
const logicalOperator = String(options.where[i].logicalOperator || 'AND').toUpperCase()
if (!currentCondition || Object.keys(currentCondition).length === 0) {
currentCondition = nextCondition
continue
}
if (!nextCondition || Object.keys(nextCondition).length === 0) {
continue
}
// 根据逻辑操作符构建新的条件结构
if (logicalOperator === 'OR') {
// 如果当前条件已经是OR结构添加新条件
if (currentCondition.OR) {
currentCondition.OR.push(nextCondition)
} else {
// 否则创建新的OR结构
currentCondition = {
OR: [currentCondition, nextCondition],
}
}
} else {
// AND
// 如果当前条件已经是AND结构添加新条件
if (currentCondition.AND) {
currentCondition.AND.push(nextCondition)
} else {
// 否则创建新的AND结构
currentCondition = {
AND: [currentCondition, nextCondition],
}
}
}
}
if (currentCondition && Object.keys(currentCondition).length > 0) {
finalConditions.push(currentCondition)
}
}
}
// 组合所有条件
if (finalConditions.length > 1) {
where.AND = finalConditions
} else if (finalConditions.length === 1) {
Object.assign(where, finalConditions[0])
}
// 构建非常基础的 SQL 预览
const whereParts: string[] = []
if (search && searchFields.length > 0) {
const like = search.replace(/'/g, "''")
whereParts.push('(' + searchFields.map(f => `${f} ILIKE '%${like}%'`).join(' OR ') + ')')
}
if (Array.isArray(options.where)) {
for (const cond of options.where) {
const op = String(cond.operator || '').toLowerCase()
const f = cond.field
const v = cond.value
const logic = String(cond.logicalOperator || 'AND').toUpperCase()
let exp = ''
switch (op) {
case 'equals':
exp = `${f} = '${v}'`
break
case 'not_equals':
exp = `${f} != '${v}'`
break
case 'contains':
exp = `${f} ILIKE '%${String(v)}%'`
break
case 'not_contains':
exp = `${f} NOT ILIKE '%${String(v)}%'`
break
case 'starts_with':
exp = `${f} ILIKE '${String(v)}%'`
break
case 'ends_with':
exp = `${f} ILIKE '%${String(v)}'`
break
case 'gt':
case 'greater_than':
exp = `${f} > ${v}`
break
case 'gte':
case 'greater_than_or_equal':
exp = `${f} >= ${v}`
break
case 'lt':
case 'less_than':
exp = `${f} < ${v}`
break
case 'lte':
case 'less_than_or_equal':
exp = `${f} <= ${v}`
break
case 'in': {
const arr = Array.isArray(v)
? v
: String(v)
.split(',')
.map(s => s.trim())
exp = `${f} IN (${arr.map(x => `'${x}'`).join(', ')})`
break
}
case 'not_in': {
const arr = Array.isArray(v)
? v
: String(v)
.split(',')
.map(s => s.trim())
exp = `${f} NOT IN (${arr.map(x => `'${x}'`).join(', ')})`
break
}
case 'is_null':
exp = `${f} IS NULL`
break
case 'is_not_null':
exp = `${f} IS NOT NULL`
break
case 'between': {
const [from, to] = Array.isArray(v) ? v : []
exp = `${f} BETWEEN '${from}' AND '${to}'`
break
}
case 'date_between': {
const [from, to] = Array.isArray(v) ? v : []
exp = `${f} BETWEEN '${from}' AND '${to}'`
break
}
}
if (exp) whereParts.push((whereParts.length > 0 ? logic + ' ' : '') + exp)
}
}
const whereSql = whereParts.length > 0 ? ' WHERE ' + whereParts.join(' ') : ''
const baseSql = `SELECT * FROM ${tableName}${whereSql}`
return { where, sqlPreview: baseSql }
}
/**
* SQL预览
* Execute normal query with pagination and a SQL preview
*/
async executeQuery(
tableName: string,
options: AdvancedQueryOptions,
searchFields: string[]
): Promise<{
data: any[]
total: number
pagination: { page: number; limit: number; totalPages: number }
executedAt: string
table: string
query: { sql: string }
}> {
const page = Number(options.pagination?.page || 1)
const limit = Number(options.pagination?.limit || 20)
const skip = (page - 1) * limit
const { where, sqlPreview } = this.buildWhereFromOptions(tableName, options, searchFields)
// 如果where条件包含id: -1说明是posts表的title IS NULL查询直接返回空结果
if (where.id === -1) {
return {
data: [],
total: 0,
pagination: {
page: Number(options.pagination?.page || 1),
limit: Number(options.pagination?.limit || 20),
totalPages: 0,
},
executedAt: new Date().toISOString(),
table: tableName,
query: { sql: sqlPreview },
}
}
// 映射到 Prisma 模型名称(简单复数到单数映射)
// 将表名转换为Prisma模型名通常是单数形式
const modelName = tableName === 'users' ? 'user' : tableName === 'posts' ? 'post' : tableName
const model = (this.prisma as any)[modelName]
// 检查模型是否存在
if (!model) {
const err: any = new Error(`未找到 Prisma 模型: ${modelName}`)
err.statusCode = 400
throw err
}
// 排序处理
const orderBy =
Array.isArray(options.orderBy) && options.orderBy.length > 0
? options.orderBy.map(s => ({
[s.field]: String(s.direction || 'ASC').toLowerCase(),
}))
: undefined
// 执行查询
const [data, total] = await Promise.all([
model.findMany({ where, skip, take: limit, orderBy }),
model.count({ where }),
])
return {
data,
total,
pagination: { page, limit, totalPages: Math.max(Math.ceil(total / limit), 1) },
executedAt: new Date().toISOString(),
table: tableName,
query: { sql: sqlPreview + ` ORDER BY ... LIMIT ${limit} OFFSET ${skip}` },
}
}
/**
* count/sum/avg/min/max
* Execute aggregate query (count/sum/avg/min/max)
*/
async executeAggregateQuery(tableName: string, options: AdvancedQueryOptions): Promise<any> {
const { where } = this.buildWhereFromOptions(tableName, options, [])
const modelName = tableName === 'users' ? 'user' : tableName
const model = (this.prisma as any)[modelName]
if (!model) {
const err: any = new Error(`未找到 Prisma 模型: ${modelName}`)
err.statusCode = 400
throw err
}
const agg = options.aggregate || {}
const params: any = { where }
if (agg.count) params._count = { _all: true }
const toFieldObj = (fields?: string[]) => {
if (!fields || fields.length === 0) return undefined
return Object.fromEntries(fields.map(f => [f, true]))
}
params._sum = toFieldObj(agg.sum)
params._avg = toFieldObj(agg.avg)
params._min = toFieldObj(agg.min)
params._max = toFieldObj(agg.max)
const result = await model.aggregate(params)
return {
executedAt: new Date().toISOString(),
result,
}
}
}
/**
* Prisma
* Create Prisma-based SQL query builder instance
*/
export function createSqlQueryBuilder(prisma: PrismaClient): PrismaSqlQueryBuilder {
return new PrismaSqlQueryBuilder(prisma)
}