Files
satusehat-service/pkg/utils/query/sql_builder.go
T
2026-04-14 01:23:34 +00:00

1190 lines
38 KiB
Go

package query
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
)
// SQLQueryBuilder implements QueryBuilder interface for SQL databases
type SQLQueryBuilder struct {
dbType DBType
dialect DatabaseDialect
sqlBuilder squirrel.StatementBuilderType
allowedColumns map[string]bool // Security: only allow specified columns
allowedTables map[string]bool // Security: only allow specified tables
// Security settings
enableSecurityChecks bool
maxAllowedRows int
// Query logging
enableQueryLogging bool
logger Logger
// Connection timeout settings
queryTimeout time.Duration
}
// Logger interface for query logging
type Logger interface {
Debugf(format string, args ...interface{})
}
// NewSQLQueryBuilder creates a new SQL query builder instance for a specific database type
func NewSQLQueryBuilder(dbType DBType) *SQLQueryBuilder {
var placeholderFormat squirrel.PlaceholderFormat
switch dbType {
case DBTypePostgreSQL:
placeholderFormat = squirrel.Dollar
case DBTypeMySQL, DBTypeSQLite:
placeholderFormat = squirrel.Question
case DBTypeSQLServer:
placeholderFormat = squirrel.AtP
default:
placeholderFormat = squirrel.Question
}
dialect := GetDialect(dbType)
return &SQLQueryBuilder{
dbType: dbType,
dialect: dialect,
sqlBuilder: squirrel.StatementBuilder.PlaceholderFormat(placeholderFormat),
allowedColumns: make(map[string]bool),
allowedTables: make(map[string]bool),
enableSecurityChecks: true,
maxAllowedRows: 10000,
enableQueryLogging: true,
queryTimeout: 30 * time.Second,
}
}
// SetLogger sets the logger for query logging
func (qb *SQLQueryBuilder) SetLogger(logger Logger) QueryBuilder {
qb.logger = logger
return qb
}
// SetSecurityOptions configures security settings
func (qb *SQLQueryBuilder) SetSecurityOptions(enableChecks bool, maxRows int) QueryBuilder {
qb.enableSecurityChecks = enableChecks
qb.maxAllowedRows = maxRows
return qb
}
// SetAllowedColumns sets the list of allowed columns for security
func (qb *SQLQueryBuilder) SetAllowedColumns(columns []string) QueryBuilder {
qb.allowedColumns = make(map[string]bool)
for _, col := range columns {
qb.allowedColumns[col] = true
}
return qb
}
// SetAllowedTables sets the list of allowed tables for security
func (qb *SQLQueryBuilder) SetAllowedTables(tables []string) QueryBuilder {
qb.allowedTables = make(map[string]bool)
for _, table := range tables {
qb.allowedTables[table] = true
}
return qb
}
// SetQueryLogging enables or disables query logging
func (qb *SQLQueryBuilder) SetQueryLogging(enable bool) QueryBuilder {
qb.enableQueryLogging = enable
return qb
}
// SetQueryTimeout sets the default query timeout
func (qb *SQLQueryBuilder) SetQueryTimeout(timeout time.Duration) QueryBuilder {
qb.queryTimeout = timeout
return qb
}
// logDebug logs debug messages if logging is enabled
func (qb *SQLQueryBuilder) logDebug(format string, args ...interface{}) {
if qb.enableQueryLogging {
if qb.logger != nil {
qb.logger.Debugf(format, args...)
} else {
// Fallback to fmt.Printf if no logger is set
fmt.Printf("[DEBUG] "+format+"\n", args...)
}
}
}
// BuildQuery builds the complete SQL SELECT query with support for CTEs, JOINs, and UNIONs
func (qb *SQLQueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) {
var allArgs []interface{}
var queryParts []string
// --- Langkah 1: Validasi ---
if qb.enableSecurityChecks && query.Limit > qb.maxAllowedRows {
return "", nil, fmt.Errorf("requested limit %d exceeds maximum allowed %d", query.Limit, qb.maxAllowedRows)
}
if qb.enableSecurityChecks && len(qb.allowedTables) > 0 && !qb.allowedTables[query.From] {
return "", nil, fmt.Errorf("disallowed table: %s", query.From)
}
// --- Langkah 2: Bangun CTEs (WITH clause) ---
if len(query.CTEs) > 0 {
cteClause, cteArgs, err := qb.buildCTEClause(query.CTEs)
if err != nil {
return "", nil, fmt.Errorf("failed to build CTE clause: %w", err)
}
queryParts = append(queryParts, cteClause)
allArgs = append(allArgs, cteArgs...)
}
// --- Langkah 3: Bangun Query Utama ---
// Ini adalah satu-satunya tempat kita memanggil buildMainQuery
mainQuery, err := qb.buildMainQuery(query)
if err != nil {
return "", nil, fmt.Errorf("failed to build main query: %w", err)
}
// Konversi mainQuery (squirrel.SelectBuilder) ke string dan args
sql, args, err := mainQuery.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to convert main query to SQL: %w", err)
}
queryParts = append(queryParts, sql)
allArgs = append(allArgs, args...)
// --- Langkah 4: Terapkan UNIONs ---
if len(query.Unions) > 0 {
unionClause, unionArgs, err := qb.buildUnionClause(query.Unions)
if err != nil {
return "", nil, fmt.Errorf("failed to build UNION clause: %w", err)
}
queryParts = append(queryParts, unionClause)
allArgs = append(allArgs, unionArgs...)
}
// --- Langkah 5: Gabungkan dan Validasi ---
finalSQL := strings.Join(queryParts, " ")
// Validasi keamanan akhir menggunakan SQL parser
if qb.enableSecurityChecks {
if err := qb.validateParsedSQL(finalSQL); err != nil {
return "", nil, fmt.Errorf("query validation failed: %w", err)
}
}
qb.logDebug("Final SQL query: %s", finalSQL)
qb.logDebug("Final query args: %v", allArgs)
return finalSQL, allArgs, nil
}
// buildMainQuery builds the main SELECT query
func (qb *SQLQueryBuilder) buildMainQuery(query DynamicQuery) (squirrel.SelectBuilder, error) {
fromClause := qb.buildFromClause(query.From, query.Aliases)
selectFields := qb.buildSelectFields(query.Fields)
// Start building the main query
mainQuery := qb.sqlBuilder.Select(selectFields...).From(fromClause)
// Add JOINs
if len(query.Joins) > 0 {
mainQuery = qb.applyJoins(mainQuery, query.Joins)
}
// Apply WHERE conditions - HANYA SEKALI
if len(query.Filters) > 0 {
// Panggil BuildWhereClause hanya satu kali
whereClause, whereArgs, err := qb.BuildWhereClause(query.Filters)
if err != nil {
return squirrel.SelectBuilder{}, fmt.Errorf("failed to build WHERE clause: %w", err)
}
// Terapkan hasilnya ke builder Squirrel hanya satu kali
mainQuery = mainQuery.Where(whereClause, whereArgs...)
}
// Apply GROUP BY
if len(query.GroupBy) > 0 {
mainQuery = mainQuery.GroupBy(qb.buildGroupByColumns(query.GroupBy)...)
}
// Apply HAVING conditions
if len(query.Having) > 0 {
havingClause, havingArgs, err := qb.BuildWhereClause(query.Having)
if err != nil {
return squirrel.SelectBuilder{}, fmt.Errorf("failed to build HAVING clause: %w", err)
}
mainQuery = mainQuery.Having(havingClause, havingArgs...)
}
// Apply ORDER BY
if len(query.Sort) > 0 {
for _, sort := range query.Sort {
column := qb.validateAndEscapeColumn(sort.Column)
if column == "" {
continue
}
order := "ASC"
if strings.ToUpper(sort.Order) == "DESC" {
order = "DESC"
}
mainQuery = mainQuery.OrderBy(fmt.Sprintf("%s %s", column, order))
}
}
// Handle window functions and JSON operations if present
if len(query.WindowFunctions) > 0 || len(query.JsonOperations) > 0 {
mainQuery = qb.applyAdvancedFeatures(mainQuery, query, selectFields, fromClause)
}
// Apply pagination with dialect-specific syntax
if query.Limit > 0 {
if qb.dbType == DBTypeSQLServer {
if len(query.Sort) == 0 {
mainQuery = mainQuery.OrderBy("(SELECT 1)")
}
mainQuery = mainQuery.Suffix(fmt.Sprintf("OFFSET %d ROWS FETCH NEXT %d ROWS ONLY", query.Offset, query.Limit))
} else {
mainQuery = mainQuery.Limit(uint64(query.Limit))
if query.Offset > 0 {
mainQuery = mainQuery.Offset(uint64(query.Offset))
}
}
} else if query.Offset > 0 && qb.dbType != DBTypeSQLServer {
mainQuery = mainQuery.Offset(uint64(query.Offset))
}
return mainQuery, nil
}
// applyJoins applies JOIN clauses to the query
func (qb *SQLQueryBuilder) applyJoins(mainQuery squirrel.SelectBuilder, joins []Join) squirrel.SelectBuilder {
for _, join := range joins {
// Security check for joined table
if qb.enableSecurityChecks && len(qb.allowedTables) > 0 && !qb.allowedTables[join.Table] {
// This should be handled at a higher level, but we'll log it
qb.logDebug("Warning: disallowed table in join: %s", join.Table)
continue
}
joinType, tableWithAlias, onClause, joinArgs, err := qb.buildSingleJoinClause(join)
if err != nil {
qb.logDebug("Warning: failed to build join clause for table %s: %v", join.Table, err)
continue
}
joinStr := tableWithAlias + " ON " + onClause
switch strings.ToUpper(joinType) {
case "LEFT":
if join.Lateral {
mainQuery = mainQuery.LeftJoin("LATERAL "+joinStr, joinArgs...)
} else {
mainQuery = mainQuery.LeftJoin(joinStr, joinArgs...)
}
case "RIGHT":
mainQuery = mainQuery.RightJoin(joinStr, joinArgs...)
case "FULL":
mainQuery = mainQuery.Join("FULL JOIN "+joinStr, joinArgs...)
default:
if join.Lateral {
mainQuery = mainQuery.Join("LATERAL "+joinStr, joinArgs...)
} else {
mainQuery = mainQuery.Join(joinStr, joinArgs...)
}
}
}
return mainQuery
}
// applyAdvancedFeatures applies window functions and JSON operations
func (qb *SQLQueryBuilder) applyAdvancedFeatures(mainQuery squirrel.SelectBuilder, query DynamicQuery, selectFields []string, fromClause string) squirrel.SelectBuilder {
// Rebuild the SELECT clause with window functions and JSON operations
var finalSelectFields []string
finalSelectFields = append(finalSelectFields, selectFields...)
// Add window functions
for _, wf := range query.WindowFunctions {
windowFunc, err := qb.buildWindowFunction(wf)
if err != nil {
qb.logDebug("Warning: failed to build window function: %v", err)
continue
}
finalSelectFields = append(finalSelectFields, windowFunc)
}
// Add JSON operations
for _, jo := range query.JsonOperations {
jsonExpr, jsonArgs, err := qb.buildJsonOperation(jo)
if err != nil {
qb.logDebug("Warning: failed to build JSON operation: %v", err)
continue
}
if jo.Alias != "" {
jsonExpr += " AS " + qb.dialect.EscapeIdentifier(jo.Alias)
}
finalSelectFields = append(finalSelectFields, jsonExpr)
// Note: JSON args would need to be handled differently in this context
_ = jsonArgs // Suppress unused warning
}
// Rebuild the query with the complete SELECT clause
return qb.sqlBuilder.Select(finalSelectFields...).From(fromClause)
}
// buildCTEClause builds the WITH clause for CTEs
func (qb *SQLQueryBuilder) buildCTEClause(ctes []CTE) (string, []interface{}, error) {
var cteParts []string
var allArgs []interface{}
for i, cte := range ctes {
// Security check for CTE table
if qb.enableSecurityChecks && len(qb.allowedTables) > 0 && !qb.allowedTables[cte.Query.From] {
return "", nil, fmt.Errorf("disallowed table in CTE: %s", cte.Query.From)
}
cteSQL, cteArgs, err := qb.BuildQuery(cte.Query)
if err != nil {
return "", nil, fmt.Errorf("failed to build CTE '%s': %w", cte.Name, err)
}
ctePart := fmt.Sprintf("%s AS (%s)", qb.dialect.EscapeIdentifier(cte.Name), cteSQL)
cteParts = append(cteParts, ctePart)
allArgs = append(allArgs, cteArgs...)
if i < len(ctes)-1 {
cteParts = append(cteParts, ",")
}
}
return "WITH " + strings.Join(cteParts, " "), allArgs, nil
}
// buildUnionClause builds UNION clauses
func (qb *SQLQueryBuilder) buildUnionClause(unions []Union) (string, []interface{}, error) {
var unionParts []string
var allArgs []interface{}
for _, union := range unions {
// Security check for union table
if qb.enableSecurityChecks && len(qb.allowedTables) > 0 && !qb.allowedTables[union.Query.From] {
return "", nil, fmt.Errorf("disallowed table in union: %s", union.Query.From)
}
unionSQL, unionArgs, err := qb.BuildQuery(union.Query)
if err != nil {
return "", nil, fmt.Errorf("failed to build union query: %w", err)
}
unionType := "UNION"
if union.Type != "" {
unionType = strings.ToUpper(union.Type)
}
unionParts = append(unionParts, unionType, unionSQL)
allArgs = append(allArgs, unionArgs...)
}
return strings.Join(unionParts, " "), allArgs, nil
}
// buildFromClause builds the FROM clause
func (qb *SQLQueryBuilder) buildFromClause(table, alias string) string {
fromClause := qb.dialect.EscapeIdentifier(table)
if alias != "" {
fromClause += " AS " + qb.dialect.EscapeIdentifier(alias)
}
return fromClause
}
// buildSelectFields builds the SELECT fields
func (qb *SQLQueryBuilder) buildSelectFields(fields []SelectField) []string {
if len(fields) == 0 {
return []string{"*"}
}
var selectFields []string
for _, field := range fields {
if field.Expression == "" {
continue
}
selectExpr := field.Expression
if field.Alias != "" {
selectExpr += " AS " + qb.dialect.EscapeIdentifier(field.Alias)
}
selectFields = append(selectFields, selectExpr)
}
if len(selectFields) == 0 {
return []string{"*"}
}
return selectFields
}
// buildGroupByColumns builds GROUP BY columns
func (qb *SQLQueryBuilder) buildGroupByColumns(columns []string) []string {
var groupByColumns []string
for _, col := range columns {
escapedCol := qb.validateAndEscapeColumn(col)
if escapedCol != "" {
groupByColumns = append(groupByColumns, escapedCol)
}
}
return groupByColumns
}
// buildSingleJoinClause builds a single JOIN clause
func (qb *SQLQueryBuilder) buildSingleJoinClause(join Join) (string, string, string, []interface{}, error) {
joinType := "INNER"
if join.Type != "" {
joinType = strings.ToUpper(join.Type)
}
tableWithAlias := qb.dialect.EscapeIdentifier(join.Table)
if join.Alias != "" {
tableWithAlias += " AS " + qb.dialect.EscapeIdentifier(join.Alias)
}
onClause, onArgs, err := qb.BuildWhereClause([]FilterGroup{join.OnConditions})
if err != nil {
return "", "", "", nil, err
}
return joinType, tableWithAlias, onClause, onArgs, nil
}
// buildWindowFunction builds a window function expression
func (qb *SQLQueryBuilder) buildWindowFunction(wf WindowFunction) (string, error) {
var funcExpr string
switch strings.ToUpper(wf.Function) {
case "ROW_NUMBER":
funcExpr = "ROW_NUMBER()"
case "RANK":
funcExpr = "RANK()"
case "DENSE_RANK":
funcExpr = "DENSE_RANK()"
case "LEAD":
funcExpr = "LEAD()"
case "LAG":
funcExpr = "LAG()"
default:
return "", fmt.Errorf("unsupported window function: %s", wf.Function)
}
overClause := "OVER ("
if wf.Over != "" {
overClause += wf.Over
}
if wf.OrderBy != "" {
if wf.Over != "" {
overClause += " "
}
overClause += "ORDER BY " + wf.OrderBy
}
if wf.Frame != "" {
if wf.Over != "" || wf.OrderBy != "" {
overClause += " "
}
overClause += wf.Frame
}
overClause += ")"
windowExpr := funcExpr + " " + overClause
if wf.Alias != "" {
windowExpr += " AS " + qb.dialect.EscapeIdentifier(wf.Alias)
}
return windowExpr, nil
}
// buildJsonOperation builds a JSON operation expression
func (qb *SQLQueryBuilder) buildJsonOperation(jo JsonOperation) (string, []interface{}, error) {
var expr string
var args []interface{}
switch jo.Type {
case "extract":
switch qb.dbType {
case DBTypePostgreSQL:
expr = fmt.Sprintf("%s->'%s'", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeMySQL:
expr = fmt.Sprintf("JSON_EXTRACT(%s, '$.%s')", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeSQLServer:
expr = fmt.Sprintf("JSON_VALUE(%s, '$.%s')", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeSQLite:
expr = fmt.Sprintf("json_extract(%s, '$.%s')", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
default:
return "", nil, fmt.Errorf("JSON operations not supported for database type: %s", qb.dbType)
}
case "exists":
switch qb.dbType {
case DBTypePostgreSQL:
expr = fmt.Sprintf("jsonb_path_exists(%s, '$.%s')", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeMySQL:
expr = fmt.Sprintf("JSON_CONTAINS_PATH(%s, 'one', '$.%s')", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeSQLServer:
expr = fmt.Sprintf("JSON_VALUE(%s, '$.%s') IS NOT NULL", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
case DBTypeSQLite:
expr = fmt.Sprintf("json_extract(%s, '$.%s') IS NOT NULL", qb.dialect.EscapeIdentifier(jo.Column), jo.Path)
default:
return "", nil, fmt.Errorf("JSON operations not supported for database type: %s", qb.dbType)
}
default:
return "", nil, fmt.Errorf("unsupported JSON operation: %s", jo.Type)
}
if jo.Alias != "" {
expr += " AS " + qb.dialect.EscapeIdentifier(jo.Alias)
}
return expr, args, nil
}
// BuildCountQuery builds a count query
func (qb *SQLQueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) {
// For a count query, we don't need fields, joins, or unions.
// We only need FROM, WHERE, GROUP BY, HAVING.
countQuery := DynamicQuery{
From: query.From,
Aliases: query.Aliases,
Filters: query.Filters,
GroupBy: query.GroupBy,
Having: query.Having,
// Joins are important for count with filters on joined tables
Joins: query.Joins,
}
// Build the base query for the count using Squirrel's From and Join methods
fromClause := qb.buildFromClause(countQuery.From, countQuery.Aliases)
baseQuery := qb.sqlBuilder.Select("COUNT(*)").From(fromClause)
// Add JOINs using Squirrel's Join method
if len(countQuery.Joins) > 0 {
for _, join := range countQuery.Joins {
// Security check for joined table
if qb.enableSecurityChecks && len(qb.allowedTables) > 0 && !qb.allowedTables[join.Table] {
return "", nil, fmt.Errorf("disallowed table in join: %s", join.Table)
}
joinType, tableWithAlias, onClause, joinArgs, err := qb.buildSingleJoinClause(join)
if err != nil {
return "", nil, err
}
joinStr := tableWithAlias + " ON " + onClause
switch strings.ToUpper(joinType) {
case "LEFT":
baseQuery = baseQuery.LeftJoin(joinStr, joinArgs...)
case "RIGHT":
baseQuery = baseQuery.RightJoin(joinStr, joinArgs...)
case "FULL":
baseQuery = baseQuery.Join("FULL JOIN "+joinStr, joinArgs...)
default:
baseQuery = baseQuery.Join(joinStr, joinArgs...)
}
}
}
if len(countQuery.Filters) > 0 {
whereClause, whereArgs, err := qb.BuildWhereClause(countQuery.Filters)
if err != nil {
return "", nil, err
}
baseQuery = baseQuery.Where(whereClause, whereArgs...)
}
if len(countQuery.GroupBy) > 0 {
baseQuery = baseQuery.GroupBy(qb.buildGroupByColumns(countQuery.GroupBy)...)
}
if len(countQuery.Having) > 0 {
havingClause, havingArgs, err := qb.BuildWhereClause(countQuery.Having)
if err != nil {
return "", nil, err
}
baseQuery = baseQuery.Having(havingClause, havingArgs...)
}
sql, args, err := baseQuery.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build COUNT query: %w", err)
}
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] COUNT SQL query: %s\n", sql)
fmt.Printf("[DEBUG] COUNT query args: %v\n", args)
}
return sql, args, nil
}
// BuildInsertQuery builds an INSERT query
func (qb *SQLQueryBuilder) BuildInsertQuery(table string, data InsertData, returningColumns ...string) (string, []interface{}, error) {
// Validate columns
for _, col := range data.Columns {
if qb.allowedColumns != nil && !qb.allowedColumns[col] {
return "", nil, fmt.Errorf("disallowed column: %s", col)
}
}
// Escape table name for case sensitivity
escapedTable := qb.dialect.EscapeIdentifier(table)
// Escape column names for case sensitivity
escapedColumns := make([]string, len(data.Columns))
for i, col := range data.Columns {
escapedColumns[i] = qb.dialect.EscapeIdentifier(col)
}
// Start with basic insert
insert := qb.sqlBuilder.Insert(escapedTable).Columns(escapedColumns...).Values(data.Values...)
// Handle JSON values - we need to modify the insert statement
if len(data.JsonValues) > 0 {
// Create a new insert builder with all columns including JSON columns
allColumns := make([]string, len(escapedColumns))
copy(allColumns, escapedColumns)
allValues := make([]interface{}, len(data.Values))
copy(allValues, data.Values)
for col, val := range data.JsonValues {
escapedJsonCol := qb.dialect.EscapeIdentifier(col)
allColumns = append(allColumns, escapedJsonCol)
jsonVal, err := json.Marshal(val)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
allValues = append(allValues, jsonVal)
}
insert = qb.sqlBuilder.Insert(escapedTable).Columns(allColumns...).Values(allValues...)
}
if len(returningColumns) > 0 {
if qb.dbType == DBTypePostgreSQL {
// Escape returning columns
escapedReturningColumns := make([]string, len(returningColumns))
for i, col := range returningColumns {
escapedReturningColumns[i] = qb.dialect.EscapeIdentifier(col)
}
insert = insert.Suffix("RETURNING " + strings.Join(escapedReturningColumns, ", "))
} else {
return "", nil, fmt.Errorf("RETURNING not supported for database type: %s", qb.dbType)
}
}
sql, args, err := insert.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build INSERT query: %w", err)
}
return sql, args, nil
}
// BuildUpdateQuery builds an UPDATE query
func (qb *SQLQueryBuilder) BuildUpdateQuery(table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) {
// Validate columns
for _, col := range updateData.Columns {
if qb.allowedColumns != nil && !qb.allowedColumns[col] {
return "", nil, fmt.Errorf("disallowed column: %s", col)
}
}
// Escape table name for case sensitivity
escapedTable := qb.dialect.EscapeIdentifier(table)
// Create set map with proper escaping
setMap := make(map[string]interface{})
for i, col := range updateData.Columns {
escapedCol := qb.dialect.EscapeIdentifier(col)
setMap[escapedCol] = updateData.Values[i]
}
// Start with basic update
update := qb.sqlBuilder.Update(escapedTable).SetMap(setMap)
// Handle JSON updates if any
if len(updateData.JsonUpdates) > 0 {
for col, jsonUpdate := range updateData.JsonUpdates {
escapedJsonCol := qb.dialect.EscapeIdentifier(col)
switch qb.dbType {
case DBTypePostgreSQL:
jsonVal, err := json.Marshal(jsonUpdate.Value)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
setMap[escapedJsonCol] = squirrel.Expr(fmt.Sprintf("jsonb_set(%s, '%s', ?)", escapedJsonCol, jsonUpdate.Path), jsonVal)
case DBTypeMySQL:
jsonVal, err := json.Marshal(jsonUpdate.Value)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
setMap[escapedJsonCol] = squirrel.Expr(fmt.Sprintf("JSON_SET(%s, '%s', ?)", escapedJsonCol, jsonUpdate.Path), jsonVal)
case DBTypeSQLServer:
jsonVal, err := json.Marshal(jsonUpdate.Value)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
setMap[escapedJsonCol] = squirrel.Expr(fmt.Sprintf("JSON_MODIFY(%s, '%s', ?)", escapedJsonCol, jsonUpdate.Path), jsonVal)
case DBTypeSQLite:
jsonVal, err := json.Marshal(jsonUpdate.Value)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
setMap[escapedJsonCol] = squirrel.Expr(fmt.Sprintf("json_patch(%s, ?)", escapedJsonCol), jsonVal)
}
}
update = qb.sqlBuilder.Update(escapedTable).SetMap(setMap)
}
if len(filters) > 0 {
whereClause, whereArgs, err := qb.BuildWhereClause(filters)
if err != nil {
return "", nil, err
}
update = update.Where(whereClause, whereArgs...)
}
if len(returningColumns) > 0 {
if qb.dbType == DBTypePostgreSQL {
escapedReturningColumns := make([]string, len(returningColumns))
for i, col := range returningColumns {
escapedReturningColumns[i] = qb.dialect.EscapeIdentifier(col)
}
update = update.Suffix("RETURNING " + strings.Join(escapedReturningColumns, ", "))
} else {
return "", nil, fmt.Errorf("RETURNING not supported for database type: %s", qb.dbType)
}
}
sql, args, err := update.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build UPDATE query: %w", err)
}
return sql, args, nil
}
// BuildDeleteQuery builds a DELETE query
func (qb *SQLQueryBuilder) BuildDeleteQuery(table string, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) {
// Escape table name for case sensitivity
escapedTable := qb.dialect.EscapeIdentifier(table)
delete := qb.sqlBuilder.Delete(escapedTable)
if len(filters) > 0 {
whereClause, whereArgs, err := qb.BuildWhereClause(filters)
if err != nil {
return "", nil, err
}
delete = delete.Where(whereClause, whereArgs...)
}
if len(returningColumns) > 0 {
if qb.dbType == DBTypePostgreSQL {
// Escape returning columns
escapedReturningColumns := make([]string, len(returningColumns))
for i, col := range returningColumns {
escapedReturningColumns[i] = qb.dialect.EscapeIdentifier(col)
}
delete = delete.Suffix("RETURNING " + strings.Join(escapedReturningColumns, ", "))
} else {
return "", nil, fmt.Errorf("RETURNING not supported for database type: %s", qb.dbType)
}
}
sql, args, err := delete.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build DELETE query: %w", err)
}
return sql, args, nil
}
// BuildUpsertQuery builds an UPSERT query
func (qb *SQLQueryBuilder) BuildUpsertQuery(table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (string, []interface{}, error) {
// Validate columns
for _, col := range insertData.Columns {
if qb.allowedColumns != nil && !qb.allowedColumns[col] {
return "", nil, fmt.Errorf("disallowed column: %s", col)
}
}
for _, col := range updateColumns {
if qb.allowedColumns != nil && !qb.allowedColumns[col] {
return "", nil, fmt.Errorf("disallowed column: %s", col)
}
}
// Escape table name for case sensitivity
escapedTable := qb.dialect.EscapeIdentifier(table)
switch qb.dbType {
case DBTypePostgreSQL:
// Handle JSON values for PostgreSQL
allColumns := make([]string, len(insertData.Columns))
copy(allColumns, insertData.Columns)
allValues := make([]interface{}, len(insertData.Values))
copy(allValues, insertData.Values)
for col, val := range insertData.JsonValues {
allColumns = append(allColumns, col)
jsonVal, err := json.Marshal(val)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
allValues = append(allValues, jsonVal)
}
insert := qb.sqlBuilder.Insert(escapedTable).Columns(allColumns...).Values(allValues...)
if len(conflictColumns) > 0 {
conflictTarget := strings.Join(conflictColumns, ", ")
setClause := ""
for _, col := range updateColumns {
if setClause != "" {
setClause += ", "
}
setClause += fmt.Sprintf("%s = EXCLUDED.%s", qb.dialect.EscapeIdentifier(col), qb.dialect.EscapeIdentifier(col))
}
insert = insert.Suffix(fmt.Sprintf("ON CONFLICT (%s) DO UPDATE SET %s", conflictTarget, setClause))
}
if len(returningColumns) > 0 {
insert = insert.Suffix("RETURNING " + strings.Join(returningColumns, ", "))
}
sql, args, err := insert.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build UPSERT query: %w", err)
}
return sql, args, nil
case DBTypeMySQL:
// Handle JSON values for MySQL
allColumns := make([]string, len(insertData.Columns))
copy(allColumns, insertData.Columns)
allValues := make([]interface{}, len(insertData.Values))
copy(allValues, insertData.Values)
for col, val := range insertData.JsonValues {
allColumns = append(allColumns, col)
jsonVal, err := json.Marshal(val)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal JSON value for column %s: %w", col, err)
}
allValues = append(allValues, jsonVal)
}
insert := qb.sqlBuilder.Insert(table).Columns(allColumns...).Values(allValues...)
if len(updateColumns) > 0 {
setClause := ""
for _, col := range updateColumns {
if setClause != "" {
setClause += ", "
}
setClause += fmt.Sprintf("%s = VALUES(%s)", qb.dialect.EscapeIdentifier(col), qb.dialect.EscapeIdentifier(col))
}
insert = insert.Suffix(fmt.Sprintf("ON DUPLICATE KEY UPDATE %s", setClause))
}
sql, args, err := insert.ToSql()
if err != nil {
return "", nil, fmt.Errorf("failed to build UPSERT query: %w", err)
}
return sql, args, nil
default:
return "", nil, fmt.Errorf("UPSERT not supported for database type: %s", qb.dbType)
}
}
// BuildWhereClause builds WHERE/HAVING conditions from FilterGroups
func (qb *SQLQueryBuilder) BuildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error) {
if len(filterGroups) == 0 {
// Jika tidak ada filter, kembalikan string kosong dan args kosong.
return "", nil, nil
}
// Slice untuk menampung semua string kondisi dari setiap group.
var groupConditions []string
// Slice untuk menampung semua argumen dari semua filter.
var allArgs []interface{}
// Iterasi setiap FilterGroup yang diberikan.
// Contoh filterGroups: [ {Filters: [filter1, filter2], LogicOp: "AND"}, {Filters: [filter3], LogicOp: "OR"} ]
for i, group := range filterGroups {
if len(group.Filters) == 0 {
// Lewati group yang tidak memiliki filter sama sekali.
qb.logDebug("Skipping empty filter group at index %d", i)
continue
}
// Bangun string kondisi untuk group saat ini (misal: "col1 = ? AND col2 LIKE ?")
// dan kumpulkan argumennya.
groupCondition, groupArgs, err := qb.buildFilterGroup(group)
if err != nil {
// Jika terjadi error saat membangun group, hentikan proses dan kembalikan error.
return "", nil, fmt.Errorf("failed to build filter group at index %d: %w", i, err)
}
// Tambahkan kondisi group yang sudah dibuat ke daftar.
// Kita bungkus dengan parentheses untuk memastikan urutan operasi yang benar.
groupConditions = append(groupConditions, fmt.Sprintf("(%s)", groupCondition))
// Tambahkan argumen dari group ini ke daftar argumen utama.
allArgs = append(allArgs, groupArgs...)
}
// Gabungkan semua kondisi group menjadi satu string WHERE.
// Kita gunakan "AND" sebagai operator default antar group utama.
// Contoh hasil: ("id" = $1 AND "name" LIKE $2) OR ("status" = $3)
whereClause := strings.Join(groupConditions, " AND ")
qb.logDebug("Final WHERE clause built: %s", whereClause)
qb.logDebug("Final WHERE args: %v", allArgs)
return whereClause, allArgs, nil
}
// buildFilterGroup membangun satu string kondisi dari sebuah FilterGroup.
// Ini adalah pembantu untuk BuildWhereClause.
func (qb *SQLQueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface{}, error) {
var conditions []string
var args []interface{}
// Tentukan operator logika untuk group ini. Defaultnya adalah "AND".
logicOp := "AND"
if group.LogicOp != "" {
logicOp = strings.ToUpper(group.LogicOp)
}
// Iterasi setiap DynamicFilter dalam group.
// Contoh group.Filters: [ {Column: "id", Operator: "_eq", Value: 5}, {Column: "name", Operator: "_like", Value: "test"} ]
for i, filter := range group.Filters {
// Bangun kondisi individual untuk setiap filter (misal: "id" = $1).
condition, filterArgs, err := qb.buildFilterCondition(filter)
if err != nil {
// Jika gagal membangun kondisi filter, hentikan proses.
return "", nil, fmt.Errorf("failed to build filter condition at index %d: %w", i, err)
}
// Jika ini bukan filter pertama, tambahkan operator logika sebelum kondisi.
if len(conditions) > 0 {
conditions = append(conditions, logicOp)
}
// Tambahkan string kondisi.
conditions = append(conditions, condition)
// Tambahkan argumen.
args = append(args, filterArgs...)
}
// Gabungkan semua kondisi dalam group dengan spasi.
// Contoh hasil: "id" = $1 AND "name" LIKE $2
groupClause := strings.Join(conditions, " ")
return groupClause, args, nil
}
// ExecuteQuery executes a query with parameters and returns rows
func (qb *SQLQueryBuilder) ExecuteQuery(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error {
sql, args, err := qb.BuildQuery(query)
if err != nil {
return err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
// Check if dest is a pointer to a slice of maps
destValue := reflect.ValueOf(dest)
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
return fmt.Errorf("dest must be a non-nil pointer")
}
destElem := destValue.Elem()
if destElem.Kind() == reflect.Slice {
sliceType := destElem.Type().Elem()
if sliceType.Kind() == reflect.Map &&
sliceType.Key().Kind() == reflect.String &&
sliceType.Elem().Kind() == reflect.Interface {
// Handle slice of map[string]interface{}
rows, err := exec.QueryxContext(ctx, sql, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
row := make(map[string]interface{})
if err := rows.MapScan(row); err != nil {
return err
}
destElem.Set(reflect.Append(destElem, reflect.ValueOf(row)))
}
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Query executed in %v\n", time.Since(start))
}
return nil
}
}
// Default case: use SelectContext
err = sqlx.SelectContext(ctx, exec, dest, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Query executed in %v\n", time.Since(start))
}
return err
}
// ExecuteQueryRow executes a query with parameters and returns a single row
func (qb *SQLQueryBuilder) ExecuteQueryRow(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error {
sql, args, err := qb.BuildQuery(query)
if err != nil {
return err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
err = sqlx.GetContext(ctx, exec, dest, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] QueryRow executed in %v\n", time.Since(start))
}
return err
}
// ExecuteCount executes a count query
func (qb *SQLQueryBuilder) ExecuteCount(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery) (int64, error) {
sql, args, err := qb.BuildCountQuery(query)
if err != nil {
return 0, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
var count int64
err = sqlx.GetContext(ctx, exec, &count, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Count query executed in %v\n", time.Since(start))
}
return count, err
}
// ExecuteInsert executes an insert operation
func (qb *SQLQueryBuilder) ExecuteInsert(ctx context.Context, exec sqlx.ExtContext, table string, data InsertData, returningColumns ...string) (sql.Result, error) {
sql, args, err := qb.BuildInsertQuery(table, data, returningColumns...)
if err != nil {
return nil, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
result, err := exec.ExecContext(ctx, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Insert executed in %v\n", time.Since(start))
}
return result, err
}
// ExecuteUpdate executes an update operation
func (qb *SQLQueryBuilder) ExecuteUpdate(ctx context.Context, exec sqlx.ExtContext, table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
sql, args, err := qb.BuildUpdateQuery(table, updateData, filters, returningColumns...)
if err != nil {
return nil, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
result, err := exec.ExecContext(ctx, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Update executed in %v\n", time.Since(start))
}
return result, err
}
// ExecuteDelete executes a delete operation
func (qb *SQLQueryBuilder) ExecuteDelete(ctx context.Context, exec sqlx.ExtContext, table string, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
sql, args, err := qb.BuildDeleteQuery(table, filters, returningColumns...)
if err != nil {
return nil, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
result, err := exec.ExecContext(ctx, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Delete executed in %v\n", time.Since(start))
}
return result, err
}
// ExecuteUpsert executes an upsert operation
func (qb *SQLQueryBuilder) ExecuteUpsert(ctx context.Context, exec sqlx.ExtContext, table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (sql.Result, error) {
sql, args, err := qb.BuildUpsertQuery(table, insertData, conflictColumns, updateColumns, returningColumns...)
if err != nil {
return nil, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && qb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, qb.queryTimeout)
defer cancel()
}
start := time.Now()
result, err := exec.ExecContext(ctx, sql, args...)
if qb.enableQueryLogging {
fmt.Printf("[DEBUG] Upsert executed in %v\n", time.Since(start))
}
return result, err
}
// buildSetMap builds a map for SetMap from UpdateData
func (qb *SQLQueryBuilder) buildSetMap(updateData UpdateData) map[string]interface{} {
setMap := make(map[string]interface{})
for i, col := range updateData.Columns {
// PERBAIKAN: Pastikan kolom yang benar di-escape
escapedCol := qb.validateAndEscapeColumn(col)
if escapedCol != "" {
setMap[escapedCol] = updateData.Values[i]
}
}
return setMap
}