Files
DianZhanDemo/server/lib/sql-query-builder.ts
ch197511161 aaaf08e8f3 init6
2025-12-11 02:09:07 +08:00

740 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}