first commit

This commit is contained in:
meninjar
2026-04-14 01:21:54 +00:00
commit 35c101725f
443 changed files with 1245931 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
# 📚 Manual Penggunaan Lengkap Pustaka Query Builder
Dokumen ini merupakan panduan komprehensif untuk menggunakan pustaka `Query Builder` (berada di `pkg/utils/query`). Pustaka ini dirancang sebagai abstraksi query dinamis yang aman dari *SQL Injection*, mendukung berbagai dialek SQL (PostgreSQL, MySQL, SQL Server, SQLite), MongoDB, serta kompatibel penuh dengan operasi transaksi.
---
## 📋 Daftar Isi
1. Inisialisasi & Konfigurasi
2. Fungsi Helper (Membuat Query Cepat)
3. Penggunaan Sederhana (CRUD Dasar SQL)
4. Penggunaan Lanjutan (Advanced SQL)
5. Transaksi (Transactions)
6. Dukungan MongoDB
7. Query Parser (API URL Parameter)
8. Daftar Operator Filter
---
## 1. Inisialisasi & Konfigurasi
Pustaka ini menyediakan beberapa cara untuk inisialisasi, baik menggunakan *Builder* langsung maupun melalui *Query Manager* yang mengumpulkan fungsi SQL, MongoDB, dan Parser di satu tempat.
```go
import "your_project/pkg/utils/query"
// A. Menggunakan Manager (Direkomendasikan)
qm := query.NewQueryManager(query.DBTypePostgreSQL)
sqlBuilder := qm.GetSQLBuilder()
mongoBuilder := qm.GetMongoBuilder()
parser := qm.GetQueryParser()
// B. Menggunakan Instansiasi Spesifik Dialek
pgBuilder := query.ForPostgreSQL()
mysqlBuilder := query.ForMySQL()
mongoBuilder := query.ForMongoDB()
// C. Menggunakan Konfigurasi Keamanan (Sangat Disarankan)
sqlBuilder.
SetSecurityOptions(true, 1000). // Enable security & set max rows
SetAllowedTables([]string{"users"}). // Whitelist tabel
SetAllowedColumns([]string{"id", "name"}).// Whitelist kolom
SetQueryLogging(true). // Aktifkan log query
SetQueryTimeout(30 * time.Second) // Timeout query
```
---
## 2. Penggunaan Sederhana (CRUD Dasar)
*Query Builder* menggunakan tipe `sqlx.ExtContext` untuk eksekutornya, yang berarti Anda dapat melemparkan `*sqlx.DB` (koneksi normal) maupun `*sqlx.Tx` (transaksi) ke dalamnya.
### A. SELECT (Mengambil Data)
```go
var users []User
// Definisi struktur query
q := query.DynamicQuery{
From: "users",
Fields: []query.SelectField{
{Expression: "id"},
{Expression: "name"},
},
Filters: []query.FilterGroup{{
LogicOp: "AND",
Filters: []query.DynamicFilter{
query.CreateEqualFilter("status", "active"),
query.CreateFilter("age", query.OpGreaterThan, 18),
},
}},
Sort: []query.SortField{
query.CreateDescSort("created_at"),
},
Limit: 10,
Offset: 0,
}
// Eksekusi query (db adalah *sqlx.DB)
err := sqlBuilder.ExecuteQuery(ctx, db, q, &users)
```
### B. INSERT (Menambah Data)
```go
insertData := query.InsertData{
Columns: []string{"name", "email", "status"},
Values: []interface{}{"John Doe", "[email protected]", "active"},
}
// Eksekusi Insert (mengembalikan sql.Result)
// Opsional: tambahkan kolom di akhir parameter untuk klausa RETURNING (hanya di Postgres)
result, err := sqlBuilder.ExecuteInsert(ctx, db, "users", insertData, "id")
```
### C. UPDATE (Mengubah Data)
```go
updateData := query.UpdateData{
Columns: []string{"status"},
Values: []interface{}{"inactive"},
}
filters := []query.FilterGroup{{
Filters: []query.DynamicFilter{
query.CreateEqualFilter("id", 1),
},
}}
result, err := sqlBuilder.ExecuteUpdate(ctx, db, "users", updateData, filters)
```
### D. DELETE (Menghapus Data)
```go
filters := []query.FilterGroup{{
Filters: []query.DynamicFilter{
query.CreateEqualFilter("id", 1),
},
}}
result, err := sqlBuilder.ExecuteDelete(ctx, db, "users", filters)
```
### E. COUNT (Menghitung Jumlah Baris)
```go
q := query.DynamicQuery{
From: "users",
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{
query.CreateEqualFilter("status", "active"),
},
}},
}
count, err := sqlBuilder.ExecuteCount(ctx, db, q)
```
---
## 3. Penggunaan Bertingkat / Lanjutan
### A. JOIN Tables
Mendukung relasi tabel (Inner, Left, Right, Full, dan Lateral Joins).
```go
q := query.DynamicQuery{
From: "orders",
Aliases: "o",
Fields: []query.SelectField{
{Expression: "o.id"},
{Expression: "u.name", Alias: "customer_name"},
},
Joins: []query.Join{
{
Type: "LEFT",
Table: "users",
Alias: "u",
OnConditions: query.FilterGroup{
Filters: []query.DynamicFilter{
// Bandingkan kolom dengan kolom lainnya (tanpa di-treat sebagai string biasa)
{Column: "o.user_id", Operator: query.OpEqual, Value: "u.id"},
},
},
},
},
}
```
### B. Common Table Expressions (CTE) / Klausa WITH
Berguna untuk membuat sub-query *temporary* yang dapat digunakan dalam query utama.
```go
q := query.DynamicQuery{
CTEs: []query.CTE{
{
Name: "active_users",
Query: query.DynamicQuery{
From: "users",
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{query.CreateEqualFilter("status", "active")},
}},
},
},
},
From: "active_users", // Query utama select dari CTE di atas
}
```
### C. Window Functions
Mendukung eksekusi fungsi analitik (cth: `ROW_NUMBER()`, `RANK()`).
```go
q := query.DynamicQuery{
From: "sales",
Fields: []query.SelectField{
{Expression: "employee_id"},
{Expression: "amount"},
},
WindowFunctions: []query.WindowFunction{
{
Function: "RANK",
Over: "PARTITION BY department_id",
OrderBy: "amount DESC",
Alias: "sales_rank",
},
},
}
```
### D. Operasi JSON
Mendukung *query* untuk kolom tipe JSON/JSONB pada tabel relasional.
```go
// Mencari dari data JSON
q := query.DynamicQuery{
From: "products",
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{
{
Column: "attributes",
Operator: query.OpJsonEqual,
Value: "red",
Options: map[string]interface{}{"path": "color"}, // Akan di-parse: attributes->'color' = 'red'
},
},
}},
}
```
---
## 4. Penggunaan dengan Transaksi (Transaction)
`QueryBuilder` telah dirancang untuk mendukung *Transactions* secara mulus. Semua metode eksekusi (seperti `ExecuteInsert`, `ExecuteUpdate`, `ExecuteQuery`) menerima tipe parameter antarmuka `sqlx.ExtContext`.
Oleh karena itu, Anda cukup memberikan variabel `*sqlx.Tx` menggantikan `*sqlx.DB`!
**Contoh Transaksi Lengkap:**
```go
func CreateUserWithProfile(ctx context.Context, db *sqlx.DB, sqlBuilder query.QueryBuilder) error {
// 1. Mulai Transaksi
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return err
}
// Buat fungsi defer untuk auto-rollback jika terjadi panic / error tanpa komit
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
} else if err != nil {
tx.Rollback()
}
}()
// 2. Operasi PERTAMA dalam transaksi (Insert User)
userData := query.InsertData{
Columns: []string{"name", "email"},
Values: []interface{}{"Jane Doe", "[email protected]"},
}
// PERHATIKAN: Kita melemparkan "tx", bukan "db"
// Kita menggunakan klausa returning "id" untuk mendapatkan ID user yang baru
res, err := sqlBuilder.ExecuteInsert(ctx, tx, "users", userData, "id")
if err != nil {
return err // Akan trigger tx.Rollback() melalui defer
}
// (Jika pakai PostgreSQL, ambil ID melalui interface driver, atau pakai ExecuteQueryRow)
var newUserID int64 = 1 // Simulasi ID yang didapat
// 3. Operasi KEDUA dalam transaksi (Insert Profile)
profileData := query.InsertData{
Columns: []string{"user_id", "bio"},
Values: []interface{}{newUserID, "Hello I am Jane"},
}
_, err = sqlBuilder.ExecuteInsert(ctx, tx, "profiles", profileData)
if err != nil {
return err // Akan trigger tx.Rollback() melalui defer
}
// 4. Komit Transaksi Jika Semua Berhasil
err = tx.Commit()
return err
}
```
---
## 5. Dukungan MongoDB
Selain SQL, *Query Builder* juga menyediakan antarmuka khusus untuk MongoDB melalui `MongoQueryBuilder`. Objek `DynamicQuery` yang sama bisa digunakan untuk mem-parsing sintaks MongoDB (sehingga cocok dengan arsitektur multi-database).
```go
// Inisialisasi
mongoQb := query.CreateMongoQueryBuilder()
// Objek Dynamic Query (Sama seperti SQL)
q := query.DynamicQuery{
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{
query.CreateEqualFilter("status", "active"),
},
}},
Limit: 10,
}
// Eksekusi Find
var results []bson.M
// Parameter kedua adalah objek *mongo.Collection
err := mongoQb.ExecuteFind(ctx, myMongoCollection, q, &results)
```
Mendukung operasi MongoDB lain seperti `ExecuteCount`, `ExecuteInsert`, `ExecuteUpdate`, `ExecuteDelete`, dan `ExecuteAggregate`.
---
## 6. Parser URL (Mengubah Parameter API menjadi Query)
Sangat bermanfaat untuk skenario REST API di mana _client_ memberikan filter, sort, dan paginasi melalui *query string* (misal: `?limit=10&sort=-created_at&filter[status][_eq]=active`).
```go
func GetUsersHandler(c *gin.Context) {
// Ambil parameter querystring dari request URL
urlParams := c.Request.URL.Query()
// Inisialisasi Parser
parser := query.CreateQueryParser().SetLimits(10, 100)
// Konversi URL Parameters menjadi objek DynamicQuery
dynamicQuery, err := parser.ParseQuery(urlParams, "users")
if err != nil {
// Handle error (bad request)
}
// Eksekusi query dengan SQL Builder
var users []User
sqlBuilder.ExecuteQuery(ctx, db, dynamicQuery, &users)
// Return response...
}
```
+403
View File
@@ -0,0 +1,403 @@
package query
import (
"context"
"database/sql"
"github.com/jmoiron/sqlx"
"go.mongodb.org/mongo-driver/mongo"
)
// Main entry point for backward compatibility
// This file provides the main interface and factory functions
// QueryBuilderFactory creates query builders for different database types
type QueryBuilderFactory interface {
CreateSQLQueryBuilder(dbType DBType) QueryBuilder
CreateMongoQueryBuilder() MongoQueryBuilder
CreateQueryParser() QueryParser
}
// DefaultQueryBuilderFactory implements QueryBuilderFactory
type DefaultQueryBuilderFactory struct{}
// NewQueryBuilderFactory creates a new query builder factory
func NewQueryBuilderFactory() QueryBuilderFactory {
return &DefaultQueryBuilderFactory{}
}
// CreateSQLQueryBuilder creates a new SQL query builder
func (f *DefaultQueryBuilderFactory) CreateSQLQueryBuilder(dbType DBType) QueryBuilder {
return NewSQLQueryBuilder(dbType)
}
// CreateMongoQueryBuilder creates a new MongoDB query builder
func (f *DefaultQueryBuilderFactory) CreateMongoQueryBuilder() MongoQueryBuilder {
return NewMongoQueryBuilder()
}
// CreateQueryParser creates a new query parser
func (f *DefaultQueryBuilderFactory) CreateQueryParser() QueryParser {
return NewQueryParser()
}
// QueryManager provides a unified interface for all query operations
type QueryManager struct {
SQLBuilder QueryBuilder
MongoBuilder MongoQueryBuilder
QueryParser QueryParser
}
// NewQueryManager creates a new query manager with all builders
func NewQueryManager(dbType DBType) *QueryManager {
return &QueryManager{
SQLBuilder: NewSQLQueryBuilder(dbType),
MongoBuilder: NewMongoQueryBuilder(),
QueryParser: NewQueryParser(),
}
}
// NewQueryManagerWithFactory creates a new query manager using factory
func NewQueryManagerWithFactory(factory QueryBuilderFactory, dbType DBType) *QueryManager {
return &QueryManager{
SQLBuilder: factory.CreateSQLQueryBuilder(dbType),
MongoBuilder: factory.CreateMongoQueryBuilder(),
QueryParser: factory.CreateQueryParser(),
}
}
// GetSQLBuilder returns the SQL query builder
func (qm *QueryManager) GetSQLBuilder() QueryBuilder {
return qm.SQLBuilder
}
// GetMongoBuilder returns the MongoDB query builder
func (qm *QueryManager) GetMongoBuilder() MongoQueryBuilder {
return qm.MongoBuilder
}
// GetQueryParser returns the query parser
func (qm *QueryManager) GetQueryParser() QueryParser {
return qm.QueryParser
}
// ExecuteSQLQuery executes a SQL query with the SQL builder
func (qm *QueryManager) ExecuteSQLQuery(ctx context.Context, db *sqlx.DB, query DynamicQuery, dest interface{}) error {
return qm.SQLBuilder.ExecuteQuery(ctx, db, query, dest)
}
// ExecuteMongoQuery executes a MongoDB query with the MongoDB builder
func (qm *QueryManager) ExecuteMongoQuery(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
return qm.MongoBuilder.ExecuteFind(ctx, collection, query, dest)
}
// ParseQuery parses URL query parameters into a DynamicQuery
func (qm *QueryManager) ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error) {
return qm.QueryParser.ParseQuery(values, defaultTable)
}
// ParseQueryWithDefaultFields parses URL query parameters with default fields
func (qm *QueryManager) ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
return qm.QueryParser.ParseQueryWithDefaultFields(values, defaultTable, defaultFields)
}
// Convenience functions for common operations
// CreateQueryBuilder creates a query builder with default settings
func CreateQueryBuilder(dbType DBType) QueryBuilder {
return NewSQLQueryBuilder(dbType).
SetSecurityOptions(true, 1000).
SetQueryLogging(true).
SetQueryTimeout(30)
}
// CreateMongoQueryBuilder creates a MongoDB query builder with default settings
func CreateMongoQueryBuilder() MongoQueryBuilder {
return NewMongoQueryBuilder().
SetSecurityOptions(true, 1000).
SetQueryLogging(true).
SetQueryTimeout(30)
}
// CreateQueryParser creates a query parser with default settings
func CreateQueryParser() QueryParser {
return NewQueryParser().SetLimits(10, 100)
}
// ExecuteQuery is a convenience function for executing SQL queries
func ExecuteQuery(ctx context.Context, db *sqlx.DB, dbType DBType, query DynamicQuery, dest interface{}) error {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteQuery(ctx, db, query, dest)
}
// ExecuteMongoQuery is a convenience function for executing MongoDB queries
func ExecuteMongoQuery(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
builder := CreateMongoQueryBuilder()
return builder.ExecuteFind(ctx, collection, query, dest)
}
// ParseURLQuery is a convenience function for parsing URL queries
func ParseURLQuery(urlValues interface{}, defaultTable string) (DynamicQuery, error) {
parser := CreateQueryParser()
return parser.ParseQuery(urlValues, defaultTable)
}
// ParseURLQueryWithFields is a convenience function for parsing URL queries with default fields
func ParseURLQueryWithFields(urlValues interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
parser := CreateQueryParser()
return parser.ParseQueryWithDefaultFields(urlValues, defaultTable, defaultFields)
}
// Database-specific helpers
// ForPostgreSQL creates a PostgreSQL-specific query builder
func ForPostgreSQL() QueryBuilder {
return NewSQLQueryBuilder(DBTypePostgreSQL)
}
// ForMySQL creates a MySQL-specific query builder
func ForMySQL() QueryBuilder {
return NewSQLQueryBuilder(DBTypeMySQL)
}
// ForSQLite creates a SQLite-specific query builder
func ForSQLite() QueryBuilder {
return NewSQLQueryBuilder(DBTypeSQLite)
}
// ForSQLServer creates a SQL Server-specific query builder
func ForSQLServer() QueryBuilder {
return NewSQLQueryBuilder(DBTypeSQLServer)
}
// ForMongoDB creates a MongoDB-specific query builder
func ForMongoDB() MongoQueryBuilder {
return NewMongoQueryBuilder()
}
// Query execution helpers
// ExecuteCount executes a count query
func ExecuteCount(ctx context.Context, db *sqlx.DB, dbType DBType, query DynamicQuery) (int64, error) {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteCount(ctx, db, query)
}
// ExecuteInsert executes an insert query
func ExecuteInsert(ctx context.Context, db *sqlx.DB, dbType DBType, table string, data InsertData, returningColumns ...string) (sql.Result, error) {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteInsert(ctx, db, table, data, returningColumns...)
}
// ExecuteUpdate executes an update query
func ExecuteUpdate(ctx context.Context, db *sqlx.DB, dbType DBType, table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteUpdate(ctx, db, table, updateData, filters, returningColumns...)
}
// ExecuteDelete executes a delete query
func ExecuteDelete(ctx context.Context, db *sqlx.DB, dbType DBType, table string, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteDelete(ctx, db, table, filters, returningColumns...)
}
// ExecuteUpsert executes an upsert query
func ExecuteUpsert(ctx context.Context, db *sqlx.DB, dbType DBType, table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (sql.Result, error) {
builder := CreateQueryBuilder(dbType)
return builder.ExecuteUpsert(ctx, db, table, insertData, conflictColumns, updateColumns, returningColumns...)
}
// MongoDB execution helpers
// ExecuteMongoCount executes a MongoDB count query
func ExecuteMongoCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error) {
builder := CreateMongoQueryBuilder()
return builder.ExecuteCount(ctx, collection, query)
}
// ExecuteMongoInsert executes a MongoDB insert query
func ExecuteMongoInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error) {
builder := CreateMongoQueryBuilder()
return builder.ExecuteInsert(ctx, collection, data)
}
// ExecuteMongoUpdate executes a MongoDB update query
func ExecuteMongoUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error) {
builder := CreateMongoQueryBuilder()
return builder.ExecuteUpdate(ctx, collection, updateData, filters)
}
// ExecuteMongoDelete executes a MongoDB delete query
func ExecuteMongoDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error) {
builder := CreateMongoQueryBuilder()
return builder.ExecuteDelete(ctx, collection, filters)
}
// ExecuteMongoAggregate executes a MongoDB aggregation query
func ExecuteMongoAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
builder := CreateMongoQueryBuilder()
return builder.ExecuteAggregate(ctx, collection, query, dest)
}
// Utility functions for creating common queries
// CreateSimpleQuery creates a simple query with basic fields
func CreateSimpleQuery(table string, fields []string, limit int) DynamicQuery {
selectFields := make([]SelectField, len(fields))
for i, field := range fields {
selectFields[i] = SelectField{Expression: field}
}
return DynamicQuery{
From: table,
Fields: selectFields,
Limit: limit,
Offset: 0,
}
}
// CreateFilterQuery creates a query with filters
func CreateFilterQuery(table string, fields []string, filters []DynamicFilter, limit int) DynamicQuery {
selectFields := make([]SelectField, len(fields))
for i, field := range fields {
selectFields[i] = SelectField{Expression: field}
}
return DynamicQuery{
From: table,
Fields: selectFields,
Filters: []FilterGroup{{Filters: filters, LogicOp: "AND"}},
Limit: limit,
Offset: 0,
}
}
// CreateSortedQuery creates a query with sorting
func CreateSortedQuery(table string, fields []string, sorts []SortField, limit int) DynamicQuery {
selectFields := make([]SelectField, len(fields))
for i, field := range fields {
selectFields[i] = SelectField{Expression: field}
}
return DynamicQuery{
From: table,
Fields: selectFields,
Sort: sorts,
Limit: limit,
Offset: 0,
}
}
// CreatePaginatedQuery creates a paginated query
func CreatePaginatedQuery(table string, fields []string, filters []DynamicFilter, sorts []SortField, limit, offset int) DynamicQuery {
selectFields := make([]SelectField, len(fields))
for i, field := range fields {
selectFields[i] = SelectField{Expression: field}
}
return DynamicQuery{
From: table,
Fields: selectFields,
Filters: []FilterGroup{{Filters: filters, LogicOp: "AND"}},
Sort: sorts,
Limit: limit,
Offset: offset,
}
}
// CreateInsertData creates insert data from a map
func CreateInsertData(data map[string]interface{}) InsertData {
columns := make([]string, 0, len(data))
values := make([]interface{}, 0, len(data))
for col, val := range data {
columns = append(columns, col)
values = append(values, val)
}
return InsertData{
Columns: columns,
Values: values,
}
}
// CreateUpdateData creates update data from a map
func CreateUpdateData(data map[string]interface{}) UpdateData {
columns := make([]string, 0, len(data))
values := make([]interface{}, 0, len(data))
for col, val := range data {
columns = append(columns, col)
values = append(values, val)
}
return UpdateData{
Columns: columns,
Values: values,
}
}
// CreateFilter creates a simple filter
func CreateFilter(column string, operator FilterOperator, value interface{}) DynamicFilter {
return DynamicFilter{
Column: column,
Operator: operator,
Value: value,
}
}
// CreateEqualFilter creates an equality filter
func CreateEqualFilter(column string, value interface{}) DynamicFilter {
return CreateFilter(column, OpEqual, value)
}
// CreateLikeFilter creates a LIKE filter
func CreateLikeFilter(column string, value string) DynamicFilter {
return CreateFilter(column, OpLike, value)
}
// CreateInFilter creates an IN filter
func CreateInFilter(column string, values []interface{}) DynamicFilter {
return CreateFilter(column, OpIn, values)
}
// CreateBetweenFilter creates a BETWEEN filter
func CreateBetweenFilter(column string, min, max interface{}) DynamicFilter {
return CreateFilter(column, OpBetween, []interface{}{min, max})
}
// CreateSort creates a sort field
func CreateSort(column string, order string) SortField {
return SortField{
Column: column,
Order: order,
}
}
// CreateAscSort creates an ascending sort
func CreateAscSort(column string) SortField {
return CreateSort(column, "ASC")
}
// CreateDescSort creates a descending sort
func CreateDescSort(column string) SortField {
return CreateSort(column, "DESC")
}
// CreateFilterGroup creates a filter group
func CreateFilterGroup(filters []DynamicFilter, logicOp string) FilterGroup {
return FilterGroup{
Filters: filters,
LogicOp: logicOp,
}
}
// CreateAndFilterGroup creates an AND filter group
func CreateAndFilterGroup(filters []DynamicFilter) FilterGroup {
return CreateFilterGroup(filters, "AND")
}
// CreateOrFilterGroup creates an OR filter group
func CreateOrFilterGroup(filters []DynamicFilter) FilterGroup {
return CreateFilterGroup(filters, "OR")
}
+591
View File
@@ -0,0 +1,591 @@
package query
import (
"fmt"
"strings"
)
// DatabaseDialect defines the interface for database-specific operations
type DatabaseDialect interface {
// SQL generation methods
Placeholder() string
EscapeIdentifier(identifier string) string
BuildLimitOffset(limit, offset int) string
BuildJsonExtract(column, path string) string
BuildJsonContains(column, path string) (string, []interface{})
BuildJsonExists(column, path string) string
BuildArrayContains(column string) string
BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error)
BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error)
BuildArrayLength(column string) string
BuildCaseInsensitiveLike(column string) string
BuildUpsert(table string, conflictColumns, updateColumns []string) string
// Type information
GetType() DBType
}
// BaseDialect provides common functionality for all dialects
type BaseDialect struct {
dbType DBType
}
func (d *BaseDialect) GetType() DBType {
return d.dbType
}
// PostgreSQLDialect implements DatabaseDialect for PostgreSQL
type PostgreSQLDialect struct {
BaseDialect
}
func NewPostgreSQLDialect() *PostgreSQLDialect {
return &PostgreSQLDialect{
BaseDialect: BaseDialect{dbType: DBTypePostgreSQL},
}
}
func (d *PostgreSQLDialect) Placeholder() string {
return "$%d"
}
func (d *PostgreSQLDialect) EscapeIdentifier(identifier string) string {
parts := strings.Split(identifier, ".")
escapedParts := make([]string, len(parts))
for i, part := range parts {
// Hindari meng-quote karakter '*' yang digunakan di COUNT(*)
if part == "*" {
escapedParts[i] = "*"
} else if len(part) > 1 && strings.HasPrefix(part, `"`) && strings.HasSuffix(part, `"`) {
// Already quoted
escapedParts[i] = part
} else {
escapedParts[i] = `"` + strings.ReplaceAll(part, `"`, `""`) + `"`
}
}
return strings.Join(escapedParts, ".")
}
func (d *PostgreSQLDialect) BuildLimitOffset(limit, offset int) string {
if limit > 0 && offset > 0 {
return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset)
} else if limit > 0 {
return fmt.Sprintf("LIMIT %d", limit)
} else if offset > 0 {
return fmt.Sprintf("OFFSET %d", offset)
}
return ""
}
func (d *PostgreSQLDialect) BuildJsonExtract(column, path string) string {
return fmt.Sprintf("%s->'%s'", column, path)
}
func (d *PostgreSQLDialect) BuildJsonContains(column, path string) (string, []interface{}) {
return fmt.Sprintf("%s @> ?", column), []interface{}{}
}
func (d *PostgreSQLDialect) BuildJsonExists(column, path string) string {
return fmt.Sprintf("jsonb_path_exists(%s, '%s')", column, path)
}
func (d *PostgreSQLDialect) BuildArrayContains(column string) string {
return fmt.Sprintf("? = ANY(%s)", column)
}
func (d *PostgreSQLDialect) BuildArrayLength(column string) string {
return fmt.Sprintf("array_length(%s, 1)", column)
}
func (d *PostgreSQLDialect) BuildCaseInsensitiveLike(column string) string {
return fmt.Sprintf("%s ILIKE ?", column)
}
func (d *PostgreSQLDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
if len(conflictColumns) == 0 {
return ""
}
conflictTarget := strings.Join(conflictColumns, ", ")
setClause := ""
for _, col := range updateColumns {
if setClause != "" {
setClause += ", "
}
setClause += fmt.Sprintf("%s = EXCLUDED.%s", d.EscapeIdentifier(col), d.EscapeIdentifier(col))
}
return fmt.Sprintf("ON CONFLICT (%s) DO UPDATE SET %s", conflictTarget, setClause)
}
func (d *PostgreSQLDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
path := "$"
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
path = pathOption
}
var expr string
var args []interface{}
switch filter.Operator {
case OpJsonContains:
expr = fmt.Sprintf("%s @> ?", column)
args = append(args, filter.Value)
case OpJsonNotContains:
expr = fmt.Sprintf("NOT (%s @> ?)", column)
args = append(args, filter.Value)
case OpJsonExists:
expr = fmt.Sprintf("jsonb_path_exists(%s, '%s')", column, path)
case OpJsonNotExists:
expr = fmt.Sprintf("NOT jsonb_path_exists(%s, '%s')", column, path)
case OpJsonEqual:
expr = fmt.Sprintf("%s->>'%s' = ?", column, path)
args = append(args, filter.Value)
case OpJsonNotEqual:
expr = fmt.Sprintf("%s->>'%s' <> ?", column, path)
args = append(args, filter.Value)
default:
return "", nil, fmt.Errorf("unsupported JSON operator for PostgreSQL: %s", filter.Operator)
}
return expr, args, nil
}
func (d *PostgreSQLDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
var expr string
var args []interface{}
switch filter.Operator {
case OpArrayContains:
expr = fmt.Sprintf("? = ANY(%s)", column)
args = append(args, filter.Value)
case OpArrayNotContains:
expr = fmt.Sprintf("? <> ALL(%s)", column)
args = append(args, filter.Value)
case OpArrayLength:
if lengthOption, ok := filter.Options["length"].(int); ok {
expr = fmt.Sprintf("array_length(%s, 1) = ?", column)
args = append(args, lengthOption)
} else {
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
}
default:
return "", nil, fmt.Errorf("unsupported array operator for PostgreSQL: %s", filter.Operator)
}
return expr, args, nil
}
// MySQLDialect implements DatabaseDialect for MySQL
type MySQLDialect struct {
BaseDialect
}
func NewMySQLDialect() *MySQLDialect {
return &MySQLDialect{
BaseDialect: BaseDialect{dbType: DBTypeMySQL},
}
}
func (d *MySQLDialect) Placeholder() string {
return "?"
}
func (d *MySQLDialect) EscapeIdentifier(identifier string) string {
parts := strings.Split(identifier, ".")
escapedParts := make([]string, len(parts))
for i, part := range parts {
if part == "*" {
escapedParts[i] = "*"
} else if len(part) > 1 && strings.HasPrefix(part, "`") && strings.HasSuffix(part, "`") {
// Already quoted
escapedParts[i] = part
} else {
escapedParts[i] = "`" + strings.ReplaceAll(part, "`", "``") + "`"
}
}
return strings.Join(escapedParts, ".")
}
func (d *MySQLDialect) BuildLimitOffset(limit, offset int) string {
if limit > 0 && offset > 0 {
return fmt.Sprintf("LIMIT %d, %d", offset, limit)
} else if limit > 0 {
return fmt.Sprintf("LIMIT %d", limit)
} else if offset > 0 {
return fmt.Sprintf("LIMIT %d, 18446744073709551615", offset)
}
return ""
}
func (d *MySQLDialect) BuildJsonExtract(column, path string) string {
return fmt.Sprintf("JSON_EXTRACT(%s, '$.%s')", column, path)
}
func (d *MySQLDialect) BuildJsonContains(column, path string) (string, []interface{}) {
return fmt.Sprintf("JSON_CONTAINS(%s, ?, '$.%s')", column, path), []interface{}{}
}
func (d *MySQLDialect) BuildJsonExists(column, path string) string {
return fmt.Sprintf("JSON_CONTAINS_PATH(%s, 'one', '$.%s')", column, path)
}
func (d *MySQLDialect) BuildArrayContains(column string) string {
return fmt.Sprintf("JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
}
func (d *MySQLDialect) BuildArrayLength(column string) string {
return fmt.Sprintf("JSON_LENGTH(%s)", column)
}
func (d *MySQLDialect) BuildCaseInsensitiveLike(column string) string {
return fmt.Sprintf("LOWER(%s) LIKE LOWER(?)", column)
}
func (d *MySQLDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
if len(updateColumns) == 0 {
return ""
}
setClause := ""
for _, col := range updateColumns {
if setClause != "" {
setClause += ", "
}
setClause += fmt.Sprintf("%s = VALUES(%s)", d.EscapeIdentifier(col), d.EscapeIdentifier(col))
}
return fmt.Sprintf("ON DUPLICATE KEY UPDATE %s", setClause)
}
func (d *MySQLDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
path := "$"
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
path = pathOption
}
var expr string
var args []interface{}
switch filter.Operator {
case OpJsonContains:
expr = fmt.Sprintf("JSON_CONTAINS(%s, ?, '%s')", column, path)
args = append(args, filter.Value)
case OpJsonNotContains:
expr = fmt.Sprintf("NOT JSON_CONTAINS(%s, ?, '%s')", column, path)
args = append(args, filter.Value)
case OpJsonExists:
expr = fmt.Sprintf("JSON_CONTAINS_PATH(%s, 'one', '%s')", column, path)
case OpJsonNotExists:
expr = fmt.Sprintf("NOT JSON_CONTAINS_PATH(%s, 'one', '%s')", column, path)
case OpJsonEqual:
expr = fmt.Sprintf("JSON_EXTRACT(%s, '%s') = ?", column, path)
args = append(args, filter.Value)
case OpJsonNotEqual:
expr = fmt.Sprintf("JSON_EXTRACT(%s, '%s') <> ?", column, path)
args = append(args, filter.Value)
default:
return "", nil, fmt.Errorf("unsupported JSON operator for MySQL: %s", filter.Operator)
}
return expr, args, nil
}
func (d *MySQLDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
var expr string
var args []interface{}
switch filter.Operator {
case OpArrayContains:
expr = fmt.Sprintf("JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
args = append(args, filter.Value)
case OpArrayNotContains:
expr = fmt.Sprintf("NOT JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
args = append(args, filter.Value)
case OpArrayLength:
if lengthOption, ok := filter.Options["length"].(int); ok {
expr = fmt.Sprintf("JSON_LENGTH(%s) = ?", column)
args = append(args, lengthOption)
} else {
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
}
default:
return "", nil, fmt.Errorf("unsupported array operator for MySQL: %s", filter.Operator)
}
return expr, args, nil
}
// SQLiteDialect implements DatabaseDialect for SQLite
type SQLiteDialect struct {
BaseDialect
}
func NewSQLiteDialect() *SQLiteDialect {
return &SQLiteDialect{
BaseDialect: BaseDialect{dbType: DBTypeSQLite},
}
}
func (d *SQLiteDialect) Placeholder() string {
return "?"
}
func (d *SQLiteDialect) EscapeIdentifier(identifier string) string {
parts := strings.Split(identifier, ".")
escapedParts := make([]string, len(parts))
for i, part := range parts {
if part == "*" {
escapedParts[i] = "*"
} else if len(part) > 1 && strings.HasPrefix(part, `"`) && strings.HasSuffix(part, `"`) {
// Already quoted
escapedParts[i] = part
} else {
escapedParts[i] = `"` + strings.ReplaceAll(part, `"`, `""`) + `"`
}
}
return strings.Join(escapedParts, ".")
}
func (d *SQLiteDialect) BuildLimitOffset(limit, offset int) string {
if limit > 0 && offset > 0 {
return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset)
} else if limit > 0 {
return fmt.Sprintf("LIMIT %d", limit)
} else if offset > 0 {
return fmt.Sprintf("OFFSET %d", offset)
}
return ""
}
func (d *SQLiteDialect) BuildJsonExtract(column, path string) string {
return fmt.Sprintf("json_extract(%s, '$.%s')", column, path)
}
func (d *SQLiteDialect) BuildJsonContains(column, path string) (string, []interface{}) {
return fmt.Sprintf("json_extract(%s, '$.%s') = ?", column, path), []interface{}{}
}
func (d *SQLiteDialect) BuildJsonExists(column, path string) string {
return fmt.Sprintf("json_extract(%s, '$.%s') IS NOT NULL", column, path)
}
func (d *SQLiteDialect) BuildArrayContains(column string) string {
return fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
}
func (d *SQLiteDialect) BuildArrayLength(column string) string {
return fmt.Sprintf("json_array_length(%s)", column)
}
func (d *SQLiteDialect) BuildCaseInsensitiveLike(column string) string {
return fmt.Sprintf("%s LIKE ?", column)
}
func (d *SQLiteDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
// SQLite doesn't have ON CONFLICT for INSERT, use INSERT OR REPLACE or separate logic
return ""
}
func (d *SQLiteDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
path := "$"
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
path = pathOption
}
var expr string
var args []interface{}
switch filter.Operator {
case OpJsonContains:
expr = fmt.Sprintf("json_extract(%s, '%s') = ?", column, path)
args = append(args, filter.Value)
case OpJsonNotContains:
expr = fmt.Sprintf("json_extract(%s, '%s') <> ?", column, path)
args = append(args, filter.Value)
case OpJsonExists:
expr = fmt.Sprintf("json_extract(%s, '%s') IS NOT NULL", column, path)
case OpJsonNotExists:
expr = fmt.Sprintf("json_extract(%s, '%s') IS NULL", column, path)
case OpJsonEqual:
expr = fmt.Sprintf("json_extract(%s, '%s') = ?", column, path)
args = append(args, filter.Value)
case OpJsonNotEqual:
expr = fmt.Sprintf("json_extract(%s, '%s') <> ?", column, path)
args = append(args, filter.Value)
default:
return "", nil, fmt.Errorf("unsupported JSON operator for SQLite: %s", filter.Operator)
}
return expr, args, nil
}
func (d *SQLiteDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
var expr string
var args []interface{}
switch filter.Operator {
case OpArrayContains:
expr = fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
args = append(args, filter.Value)
case OpArrayNotContains:
expr = fmt.Sprintf("NOT EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
args = append(args, filter.Value)
case OpArrayLength:
if lengthOption, ok := filter.Options["length"].(int); ok {
expr = fmt.Sprintf("json_array_length(%s) = ?", column)
args = append(args, lengthOption)
} else {
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
}
default:
return "", nil, fmt.Errorf("unsupported array operator for SQLite: %s", filter.Operator)
}
return expr, args, nil
}
// SQLServerDialect implements DatabaseDialect for SQL Server
type SQLServerDialect struct {
BaseDialect
}
func NewSQLServerDialect() *SQLServerDialect {
return &SQLServerDialect{
BaseDialect: BaseDialect{dbType: DBTypeSQLServer},
}
}
func (d *SQLServerDialect) Placeholder() string {
return "@p%d"
}
func (d *SQLServerDialect) EscapeIdentifier(identifier string) string {
parts := strings.Split(identifier, ".")
escapedParts := make([]string, len(parts))
for i, part := range parts {
if part == "*" {
escapedParts[i] = "*"
} else if len(part) > 1 && strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") {
// Already quoted
escapedParts[i] = part
} else {
escapedParts[i] = "[" + strings.ReplaceAll(part, "]", "]]") + "]"
}
}
return strings.Join(escapedParts, ".")
}
func (d *SQLServerDialect) BuildLimitOffset(limit, offset int) string {
if limit > 0 && offset > 0 {
return fmt.Sprintf("OFFSET %d ROWS FETCH NEXT %d ROWS ONLY", offset, limit)
} else if limit > 0 {
return fmt.Sprintf("FETCH NEXT %d ROWS ONLY", limit)
} else if offset > 0 {
return fmt.Sprintf("OFFSET %d ROWS", offset)
}
return ""
}
func (d *SQLServerDialect) BuildJsonExtract(column, path string) string {
return fmt.Sprintf("JSON_VALUE(%s, '%s')", column, d.escapeSqlServerJsonPath(path))
}
func (d *SQLServerDialect) BuildJsonContains(column, path string) (string, []interface{}) {
return fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path)), []interface{}{}
}
func (d *SQLServerDialect) BuildJsonExists(column, path string) string {
return fmt.Sprintf("JSON_VALUE(%s, '%s') IS NOT NULL", column, d.escapeSqlServerJsonPath(path))
}
func (d *SQLServerDialect) BuildArrayContains(column string) string {
return fmt.Sprintf("? IN (SELECT value FROM OPENJSON(%s))", column)
}
func (d *SQLServerDialect) BuildArrayLength(column string) string {
return fmt.Sprintf("(SELECT COUNT(*) FROM OPENJSON(%s))", column)
}
func (d *SQLServerDialect) BuildCaseInsensitiveLike(column string) string {
return fmt.Sprintf("LOWER(%s) LIKE LOWER(?)", column)
}
func (d *SQLServerDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
// SQL Server uses MERGE for upsert operations
return ""
}
func (d *SQLServerDialect) escapeSqlServerJsonPath(path string) string {
// Convert $.path to proper SQL Server JSON path format
if strings.HasPrefix(path, "$.") {
return path
}
return "$." + path
}
func (d *SQLServerDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
path := "$"
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
path = pathOption
}
var expr string
var args []interface{}
switch filter.Operator {
case OpJsonContains:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path))
args = append(args, filter.Value)
case OpJsonNotContains:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') <> ?", column, d.escapeSqlServerJsonPath(path))
args = append(args, filter.Value)
case OpJsonExists:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') IS NOT NULL", column, d.escapeSqlServerJsonPath(path))
case OpJsonNotExists:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') IS NULL", column, d.escapeSqlServerJsonPath(path))
case OpJsonEqual:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path))
args = append(args, filter.Value)
case OpJsonNotEqual:
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') <> ?", column, d.escapeSqlServerJsonPath(path))
args = append(args, filter.Value)
default:
return "", nil, fmt.Errorf("unsupported JSON operator for SQL Server: %s", filter.Operator)
}
return expr, args, nil
}
func (d *SQLServerDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
var expr string
var args []interface{}
switch filter.Operator {
case OpArrayContains:
expr = fmt.Sprintf("? IN (SELECT value FROM OPENJSON(%s))", column)
args = append(args, filter.Value)
case OpArrayNotContains:
expr = fmt.Sprintf("? NOT IN (SELECT value FROM OPENJSON(%s))", column)
args = append(args, filter.Value)
case OpArrayLength:
if lengthOption, ok := filter.Options["length"].(int); ok {
expr = fmt.Sprintf("(SELECT COUNT(*) FROM OPENJSON(%s)) = ?", column)
args = append(args, lengthOption)
} else {
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
}
default:
return "", nil, fmt.Errorf("unsupported array operator for SQL Server: %s", filter.Operator)
}
return expr, args, nil
}
// GetDialect returns the appropriate dialect for the given database type
func GetDialect(dbType DBType) DatabaseDialect {
switch dbType {
case DBTypePostgreSQL:
return NewPostgreSQLDialect()
case DBTypeMySQL:
return NewMySQLDialect()
case DBTypeSQLite:
return NewSQLiteDialect()
case DBTypeSQLServer:
return NewSQLServerDialect()
default:
return NewPostgreSQLDialect() // Default fallback
}
}
+166
View File
@@ -0,0 +1,166 @@
package query
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPostgreSQLDialect_EscapeIdentifier(t *testing.T) {
dialect := NewPostgreSQLDialect()
testCases := []struct {
name string
input string
expected string
}{
{
name: "Simple lowercase",
input: "users",
expected: `"users"`,
},
{
name: "PascalCase with underscore",
input: "Nama_device",
expected: `"Nama_device"`,
},
{
name: "PascalCase",
input: "Tahun",
expected: `"Tahun"`,
},
{
name: "Schema and table",
input: "role_access.rol_permission",
expected: `"role_access"."rol_permission"`,
},
{
name: "Wildcard should not be escaped",
input: "*",
expected: `*`,
},
{
name: "Identifier with quotes inside",
input: `user"s`,
expected: `"user""s"`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := dialect.EscapeIdentifier(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestMySQLDialect_EscapeIdentifier(t *testing.T) {
dialect := NewMySQLDialect()
testCases := []struct {
name string
input string
expected string
}{
{
name: "Simple lowercase",
input: "users",
expected: "`users`",
},
{
name: "PascalCase with underscore",
input: "Nama_device",
expected: "`Nama_device`",
},
{
name: "PascalCase",
input: "Tahun",
expected: "`Tahun`",
},
{
name: "Schema and table",
input: "role_access.rol_permission",
expected: "`role_access`.`rol_permission`",
},
{
name: "Wildcard should not be escaped",
input: "*",
expected: `*`,
},
{
name: "Identifier with backticks inside",
input: "user`s",
expected: "`user``s`",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := dialect.EscapeIdentifier(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestSQLServerDialect_EscapeIdentifier(t *testing.T) {
dialect := NewSQLServerDialect()
testCases := []struct {
name string
input string
expected string
}{
{
name: "Simple lowercase",
input: "users",
expected: "[users]",
},
{
name: "PascalCase with underscore",
input: "Nama_device",
expected: "[Nama_device]",
},
{
name: "PascalCase",
input: "Tahun",
expected: "[Tahun]",
},
{
name: "Schema and table",
input: "role_access.rol_permission",
expected: "[role_access].[rol_permission]",
},
{
name: "Wildcard should not be escaped",
input: "*",
expected: `*`,
},
{
name: "Identifier with brackets inside",
input: "user]s",
expected: "[user]]s]",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := dialect.EscapeIdentifier(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestSQLiteDialect_EscapeIdentifier(t *testing.T) {
dialect := NewSQLiteDialect()
// SQLite uses the same quoting as PostgreSQL
t.Run("PascalCase with underscore", func(t *testing.T) {
result := dialect.EscapeIdentifier("Nama_device")
assert.Equal(t, `"Nama_device"`, result)
})
t.Run("Schema and table", func(t *testing.T) {
result := dialect.EscapeIdentifier("role_access.rol_permission")
assert.Equal(t, `"role_access"."rol_permission"`, result)
})
}
+221
View File
@@ -0,0 +1,221 @@
package query
import (
"os"
"strconv"
"strings"
"time"
)
// getDefaultPort returns default port for database type
func getDefaultPort(dbType string) int {
switch dbType {
case "postgres":
return 5432
case "mysql":
return 3306
case "sqlserver":
return 1433
case "mongodb":
return 27017
case "sqlite":
return 0 // SQLite doesn't use port
default:
return 5432
}
}
// getDefaultSchema returns default schema for database type
func getDefaultSchema(dbType string) string {
switch dbType {
case "postgres":
return "public"
case "mysql":
return ""
case "sqlserver":
return "dbo"
case "mongodb":
return ""
case "sqlite":
return ""
default:
return "public"
}
}
// getDefaultSSLMode returns default SSL mode for database type
func getDefaultSSLMode(dbType string) string {
switch dbType {
case "postgres":
return "disable"
case "mysql":
return "false"
case "sqlserver":
return "false"
case "mongodb":
return "false"
case "sqlite":
return ""
default:
return "disable"
}
}
// getDefaultMaxOpenConns returns default max open connections for database type
func getDefaultMaxOpenConns(dbType string) int {
switch dbType {
case "postgres":
return 25
case "mysql":
return 25
case "sqlserver":
return 25
case "mongodb":
return 100
case "sqlite":
return 1 // SQLite only supports one writer at a time
default:
return 25
}
}
// getDefaultMaxIdleConns returns default max idle connections for database type
func getDefaultMaxIdleConns(dbType string) int {
switch dbType {
case "postgres":
return 25
case "mysql":
return 25
case "sqlserver":
return 25
case "mongodb":
return 10
case "sqlite":
return 1 // SQLite only supports one writer at a time
default:
return 25
}
}
// getDefaultConnMaxLifetime returns default connection max lifetime for database type
func getDefaultConnMaxLifetime(dbType string) string {
switch dbType {
case "postgres":
return "5m"
case "mysql":
return "5m"
case "sqlserver":
return "5m"
case "mongodb":
return "30m"
case "sqlite":
return "5m"
default:
return "5m"
}
}
// getEnvFromMap gets value from map with default
func getEnvFromMap(config map[string]string, key, defaultValue string) string {
if value, exists := config[key]; exists {
return value
}
return defaultValue
}
// getEnvAsIntFromMap gets int value from map with default
func getEnvAsIntFromMap(config map[string]string, key string, defaultValue int) int {
if value, exists := config[key]; exists {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
// getEnvAsBoolFromMap gets bool value from map with default
func getEnvAsBoolFromMap(config map[string]string, key string, defaultValue bool) bool {
if value, exists := config[key]; exists {
if boolValue, err := strconv.ParseBool(value); err == nil {
return boolValue
}
}
return defaultValue
}
// parseSchemes parses comma-separated schemes string into a slice
func parseSchemes(schemesStr string) []string {
if schemesStr == "" {
return []string{"http"}
}
schemes := strings.Split(schemesStr, ",")
for i, scheme := range schemes {
schemes[i] = strings.TrimSpace(scheme)
}
return schemes
}
// parseStaticTokens parses comma-separated static tokens string into a slice
func parseStaticTokens(tokensStr string) []string {
if tokensStr == "" {
return []string{}
}
tokens := strings.Split(tokensStr, ",")
var result []string
for _, token := range tokens {
token = strings.TrimSpace(token)
if token != "" {
result = append(result, token)
}
}
return result
}
// parseOrigins parses comma-separated origins string into a slice
func parseOrigins(originsStr string) []string {
if originsStr == "" {
return []string{"http://localhost:8080"} // Default for development
}
origins := strings.Split(originsStr, ",")
for i, origin := range origins {
origins[i] = strings.TrimSpace(origin)
}
return origins
}
// parseDuration parses duration string
func parseDuration(durationStr string) time.Duration {
if duration, err := time.ParseDuration(durationStr); err == nil {
return duration
}
return 5 * time.Minute
}
// getEnv gets environment variable with default
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvAsInt gets environment variable as int with default
func getEnvAsInt(key string, defaultValue int) int {
valueStr := getEnv(key, "")
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
return defaultValue
}
// getEnvAsBool gets environment variable as bool with default
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := getEnv(key, "")
if value, err := strconv.ParseBool(valueStr); err == nil {
return value
}
return defaultValue
}
+461
View File
@@ -0,0 +1,461 @@
package query
import (
"fmt"
"reflect"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// MongoQueryBuilderImpl implements MongoQueryBuilder interface
type MongoQueryBuilderImpl struct {
allowedFields map[string]bool // Security: only allow specified fields
allowedCollections map[string]bool // Security: only allow specified collections
// Security settings
enableSecurityChecks bool
maxAllowedDocs int
// Query logging
enableQueryLogging bool
// Connection timeout settings
queryTimeout int
}
// NewMongoQueryBuilder creates a new MongoDB query builder instance
func NewMongoQueryBuilder() *MongoQueryBuilderImpl {
return &MongoQueryBuilderImpl{
allowedFields: make(map[string]bool),
allowedCollections: make(map[string]bool),
enableSecurityChecks: true,
maxAllowedDocs: 10000,
enableQueryLogging: true,
queryTimeout: 30,
}
}
// SetSecurityOptions configures security settings
func (mqb *MongoQueryBuilderImpl) SetSecurityOptions(enableChecks bool, maxDocs int) MongoQueryBuilder {
mqb.enableSecurityChecks = enableChecks
mqb.maxAllowedDocs = maxDocs
return mqb
}
// SetAllowedFields sets the list of allowed fields for security
func (mqb *MongoQueryBuilderImpl) SetAllowedFields(fields []string) MongoQueryBuilder {
mqb.allowedFields = make(map[string]bool)
for _, field := range fields {
mqb.allowedFields[field] = true
}
return mqb
}
// SetAllowedCollections sets the list of allowed collections for security
func (mqb *MongoQueryBuilderImpl) SetAllowedCollections(collections []string) MongoQueryBuilder {
mqb.allowedCollections = make(map[string]bool)
for _, collection := range collections {
mqb.allowedCollections[collection] = true
}
return mqb
}
// SetQueryLogging enables or disables query logging
func (mqb *MongoQueryBuilderImpl) SetQueryLogging(enable bool) MongoQueryBuilder {
mqb.enableQueryLogging = enable
return mqb
}
// SetQueryTimeout sets the default query timeout
func (mqb *MongoQueryBuilderImpl) SetQueryTimeout(timeout time.Duration) MongoQueryBuilder {
mqb.queryTimeout = int(timeout.Seconds())
return mqb
}
// BuildFindQuery builds a MongoDB find query from DynamicQuery
func (mqb *MongoQueryBuilderImpl) BuildFindQuery(query DynamicQuery) (interface{}, interface{}, error) {
filter := bson.M{}
findOptions := options.Find()
// Security check for limit
if mqb.enableSecurityChecks && query.Limit > mqb.maxAllowedDocs {
return nil, nil, fmt.Errorf("requested limit %d exceeds maximum allowed %d", query.Limit, mqb.maxAllowedDocs)
}
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
return nil, nil, fmt.Errorf("disallowed collection: %s", query.From)
}
// Build filter from DynamicQuery filters
if len(query.Filters) > 0 {
mongoFilter, err := mqb.buildFilter(query.Filters)
if err != nil {
return nil, nil, err
}
filter = mongoFilter
}
// Set projection from fields
if len(query.Fields) > 0 {
projection := bson.M{}
for _, field := range query.Fields {
if field.Expression == "*" {
// Include all fields
continue
}
fieldName := field.Expression
if field.Alias != "" {
fieldName = field.Alias
}
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
}
projection[fieldName] = 1
}
if len(projection) > 0 {
findOptions.SetProjection(projection)
}
}
// Set sort
if len(query.Sort) > 0 {
sort := bson.D{}
for _, sortField := range query.Sort {
fieldName := sortField.Column
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
}
order := 1 // ASC
if strings.ToUpper(sortField.Order) == "DESC" {
order = -1 // DESC
}
sort = append(sort, bson.E{Key: fieldName, Value: order})
}
findOptions.SetSort(sort)
}
// Set limit and offset
if query.Limit > 0 {
findOptions.SetLimit(int64(query.Limit))
}
if query.Offset > 0 {
findOptions.SetSkip(int64(query.Offset))
}
return filter, findOptions, nil
}
// BuildAggregateQuery builds a MongoDB aggregation pipeline from DynamicQuery
func (mqb *MongoQueryBuilderImpl) BuildAggregateQuery(query DynamicQuery) (interface{}, error) {
pipeline := []bson.D{}
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
return nil, fmt.Errorf("disallowed collection: %s", query.From)
}
// Handle CTEs as stages in the pipeline
if len(query.CTEs) > 0 {
for _, cte := range query.CTEs {
// Security check for CTE collection
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[cte.Query.From] {
return nil, fmt.Errorf("disallowed collection in CTE: %s", cte.Query.From)
}
subPipeline, err := mqb.BuildAggregateQuery(cte.Query)
if err != nil {
return nil, fmt.Errorf("failed to build CTE '%s': %w", cte.Name, err)
}
// Add $lookup stage for joins
if len(cte.Query.Joins) > 0 {
for _, join := range cte.Query.Joins {
// Security check for joined collection
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[join.Table] {
return nil, fmt.Errorf("disallowed collection in join: %s", join.Table)
}
lookupStage := bson.D{
{Key: "$lookup", Value: bson.D{
{Key: "from", Value: join.Table},
{Key: "localField", Value: join.Alias},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: join.Alias},
}},
}
pipeline = append(pipeline, lookupStage)
}
}
// Add the sub-pipeline
if subPipelineSlice, ok := subPipeline.([]bson.D); ok {
pipeline = append(pipeline, subPipelineSlice...)
}
}
}
// Match stage for filters
if len(query.Filters) > 0 {
filter, err := mqb.buildFilter(query.Filters)
if err != nil {
return nil, err
}
pipeline = append(pipeline, bson.D{{Key: "$match", Value: filter}})
}
// Group stage for GROUP BY
if len(query.GroupBy) > 0 {
groupID := bson.D{}
for _, field := range query.GroupBy {
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
return nil, fmt.Errorf("disallowed field: %s", field)
}
groupID = append(groupID, bson.E{Key: field, Value: "$" + field})
}
groupStage := bson.D{
{Key: "$group", Value: bson.D{
{Key: "_id", Value: groupID},
}},
}
// Add any aggregations from fields
for _, field := range query.Fields {
if strings.Contains(field.Expression, "(") && strings.Contains(field.Expression, ")") {
// This is an aggregation function
funcName := strings.Split(field.Expression, "(")[0]
funcField := strings.TrimSuffix(strings.Split(field.Expression, "(")[1], ")")
if mqb.allowedFields != nil && !mqb.allowedFields[funcField] {
return nil, fmt.Errorf("disallowed field: %s", funcField)
}
switch strings.ToLower(funcName) {
case "count":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: 1}},
})
case "sum":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: "$" + funcField}},
})
case "avg":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$avg", Value: "$" + funcField}},
})
case "min":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$min", Value: "$" + funcField}},
})
case "max":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$max", Value: "$" + funcField}},
})
}
}
}
pipeline = append(pipeline, groupStage)
}
// Sort stage
if len(query.Sort) > 0 {
sort := bson.D{}
for _, sortField := range query.Sort {
fieldName := sortField.Column
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, fmt.Errorf("disallowed field: %s", fieldName)
}
order := 1 // ASC
if strings.ToUpper(sortField.Order) == "DESC" {
order = -1 // DESC
}
sort = append(sort, bson.E{Key: fieldName, Value: order})
}
pipeline = append(pipeline, bson.D{{Key: "$sort", Value: sort}})
}
// Skip and limit stages
if query.Offset > 0 {
pipeline = append(pipeline, bson.D{{Key: "$skip", Value: query.Offset}})
}
if query.Limit > 0 {
pipeline = append(pipeline, bson.D{{Key: "$limit", Value: query.Limit}})
}
return pipeline, nil
}
// buildFilter builds a MongoDB filter from FilterGroups
func (mqb *MongoQueryBuilderImpl) buildFilter(filterGroups []FilterGroup) (bson.M, error) {
if len(filterGroups) == 0 {
return bson.M{}, nil
}
var result bson.M
var err error
for i, group := range filterGroups {
if len(group.Filters) == 0 {
continue
}
groupFilter, err := mqb.buildFilterGroup(group)
if err != nil {
return nil, err
}
if i == 0 {
result = groupFilter
} else {
logicOp := "$and"
if group.LogicOp != "" {
switch strings.ToUpper(group.LogicOp) {
case "OR":
logicOp = "$or"
}
}
result = bson.M{logicOp: []bson.M{result, groupFilter}}
}
}
return result, err
}
// buildFilterGroup builds a filter for a single filter group
func (mqb *MongoQueryBuilderImpl) buildFilterGroup(group FilterGroup) (bson.M, error) {
var filters []bson.M
logicOp := "$and"
if group.LogicOp != "" {
switch strings.ToUpper(group.LogicOp) {
case "OR":
logicOp = "$or"
}
}
for _, filter := range group.Filters {
fieldFilter, err := mqb.buildFilterCondition(filter)
if err != nil {
return nil, err
}
filters = append(filters, fieldFilter)
}
if len(filters) == 1 {
return filters[0], nil
}
return bson.M{logicOp: filters}, nil
}
// buildFilterCondition builds a single filter condition for MongoDB
func (mqb *MongoQueryBuilderImpl) buildFilterCondition(filter DynamicFilter) (bson.M, error) {
field := filter.Column
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
return nil, fmt.Errorf("disallowed field: %s", field)
}
switch filter.Operator {
case OpEqual:
return bson.M{field: filter.Value}, nil
case OpNotEqual:
return bson.M{field: bson.M{"$ne": filter.Value}}, nil
case OpIn:
values := mqb.parseArrayValue(filter.Value)
return bson.M{field: bson.M{"$in": values}}, nil
case OpNotIn:
values := mqb.parseArrayValue(filter.Value)
return bson.M{field: bson.M{"$nin": values}}, nil
case OpGreaterThan:
return bson.M{field: bson.M{"$gt": filter.Value}}, nil
case OpGreaterThanEqual:
return bson.M{field: bson.M{"$gte": filter.Value}}, nil
case OpLessThan:
return bson.M{field: bson.M{"$lt": filter.Value}}, nil
case OpLessThanEqual:
return bson.M{field: bson.M{"$lte": filter.Value}}, nil
case OpLike:
// Convert SQL LIKE to MongoDB regex
pattern := filter.Value.(string)
pattern = strings.ReplaceAll(pattern, "%", ".*")
pattern = strings.ReplaceAll(pattern, "_", ".")
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpILike:
// Case-insensitive like
pattern := filter.Value.(string)
pattern = strings.ReplaceAll(pattern, "%", ".*")
pattern = strings.ReplaceAll(pattern, "_", ".")
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpContains:
// Contains substring
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpNotContains:
// Does not contain substring
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$not": bson.M{"$regex": pattern, "$options": "i"}}}, nil
case OpStartsWith:
// Starts with
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": "^" + pattern, "$options": "i"}}, nil
case OpEndsWith:
// Ends with
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": pattern + "$", "$options": "i"}}, nil
case OpNull:
return bson.M{field: bson.M{"$exists": false}}, nil
case OpNotNull:
return bson.M{field: bson.M{"$exists": true}}, nil
case OpJsonContains:
// JSON contains
return bson.M{field: bson.M{"$elemMatch": filter.Value}}, nil
case OpJsonNotContains:
// JSON does not contain
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": filter.Value}}}, nil
case OpJsonExists:
// JSON path exists
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": true}}, nil
case OpJsonNotExists:
// JSON path does not exist
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": false}}, nil
case OpArrayContains:
// Array contains
return bson.M{field: bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}, nil
case OpArrayNotContains:
// Array does not contain
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}}, nil
case OpArrayLength:
// Array length
if lengthOption, ok := filter.Options["length"].(int); ok {
return bson.M{field: bson.M{"$size": lengthOption}}, nil
}
return nil, fmt.Errorf("array_length operator requires 'length' option")
default:
return nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
}
}
// parseArrayValue parses an array value for MongoDB
func (mqb *MongoQueryBuilderImpl) parseArrayValue(value interface{}) []interface{} {
if value == nil {
return nil
}
if reflect.TypeOf(value).Kind() == reflect.Slice {
v := reflect.ValueOf(value)
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
return result
}
if str, ok := value.(string); ok {
if strings.Contains(str, ",") {
parts := strings.Split(str, ",")
result := make([]interface{}, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
return []interface{}{str}
}
return []interface{}{value}
}
+211
View File
@@ -0,0 +1,211 @@
package query
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// ExecuteFind executes a MongoDB find query
func (mqb *MongoQueryBuilderImpl) ExecuteFind(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return fmt.Errorf("disallowed collection: %s", collection.Name())
}
filter, findOptions, err := mqb.BuildFindQuery(query)
if err != nil {
return err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
cursor, err := collection.Find(ctx, filter.(bson.M), findOptions.(*options.FindOptions))
if err != nil {
return err
}
defer cursor.Close(ctx)
err = cursor.All(ctx, dest)
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Find executed in %v\n", time.Since(start))
}
return err
}
// ExecuteAggregate executes a MongoDB aggregation pipeline
func (mqb *MongoQueryBuilderImpl) ExecuteAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return fmt.Errorf("disallowed collection: %s", collection.Name())
}
pipeline, err := mqb.BuildAggregateQuery(query)
if err != nil {
return err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
cursor, err := collection.Aggregate(ctx, pipeline.([]bson.D))
if err != nil {
return err
}
defer cursor.Close(ctx)
err = cursor.All(ctx, dest)
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Aggregate executed in %v\n", time.Since(start))
}
return err
}
// ExecuteCount executes a MongoDB count query
func (mqb *MongoQueryBuilderImpl) ExecuteCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error) {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return 0, fmt.Errorf("disallowed collection: %s", collection.Name())
}
filter, _, err := mqb.BuildFindQuery(query)
if err != nil {
return 0, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
count, err := collection.CountDocuments(ctx, filter.(bson.M))
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Count executed in %v\n", time.Since(start))
}
return count, err
}
// ExecuteInsert executes a MongoDB insert operation
func (mqb *MongoQueryBuilderImpl) ExecuteInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error) {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
}
document := bson.M{}
for i, col := range data.Columns {
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
return nil, fmt.Errorf("disallowed field: %s", col)
}
document[col] = data.Values[i]
}
// Handle JSON values
for col, val := range data.JsonValues {
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
return nil, fmt.Errorf("disallowed field: %s", col)
}
document[col] = val
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
result, err := collection.InsertOne(ctx, document)
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Insert executed in %v\n", time.Since(start))
}
return result, err
}
// ExecuteUpdate executes a MongoDB update operation
func (mqb *MongoQueryBuilderImpl) ExecuteUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error) {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
}
filter, err := mqb.buildFilter(filters)
if err != nil {
return nil, err
}
update := bson.M{"$set": bson.M{}}
for i, col := range updateData.Columns {
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
return nil, fmt.Errorf("disallowed field: %s", col)
}
update["$set"].(bson.M)[col] = updateData.Values[i]
}
// Handle JSON updates
for col, jsonUpdate := range updateData.JsonUpdates {
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
return nil, fmt.Errorf("disallowed field: %s", col)
}
// Use dot notation for nested JSON updates
update["$set"].(bson.M)[col+"."+jsonUpdate.Path] = jsonUpdate.Value
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
result, err := collection.UpdateMany(ctx, filter, update)
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Update executed in %v\n", time.Since(start))
}
return result, err
}
// ExecuteDelete executes a MongoDB delete operation
func (mqb *MongoQueryBuilderImpl) ExecuteDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error) {
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
}
filter, err := mqb.buildFilter(filters)
if err != nil {
return nil, err
}
// Set timeout if not already in context
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
defer cancel()
}
start := time.Now()
result, err := collection.DeleteMany(ctx, filter)
if mqb.enableQueryLogging {
fmt.Printf("[DEBUG] MongoDB Delete executed in %v\n", time.Since(start))
}
return result, err
}
+185
View File
@@ -0,0 +1,185 @@
package query
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// QueryParserImpl implements QueryParser interface
type QueryParserImpl struct {
defaultLimit int
maxLimit int
}
// NewQueryParser creates a new query parser instance
func NewQueryParser() *QueryParserImpl {
return &QueryParserImpl{defaultLimit: 10, maxLimit: 100}
}
// SetLimits sets default and maximum limits for pagination
func (qp *QueryParserImpl) SetLimits(defaultLimit, maxLimit int) QueryParser {
qp.defaultLimit = defaultLimit
qp.maxLimit = maxLimit
return qp
}
// ParseQuery parses URL query parameters into a DynamicQuery struct
func (qp *QueryParserImpl) ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error) {
query := DynamicQuery{
From: defaultTable,
Limit: qp.defaultLimit,
Offset: 0,
}
// Convert values to url.Values
var urlValues url.Values
switch v := values.(type) {
case url.Values:
urlValues = v
case map[string][]string:
urlValues = make(url.Values)
for key, vals := range v {
urlValues[key] = vals
}
default:
return query, fmt.Errorf("unsupported values type: %T", values)
}
// Parse fields
if fields := urlValues.Get("fields"); fields != "" {
if fields == "*" {
query.Fields = []SelectField{{Expression: "*"}}
} else {
fieldList := strings.Split(fields, ",")
for _, field := range fieldList {
query.Fields = append(query.Fields, SelectField{Expression: strings.TrimSpace(field)})
}
}
} else {
query.Fields = []SelectField{{Expression: "*"}}
}
// Parse pagination
if limit := urlValues.Get("limit"); limit != "" {
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= qp.maxLimit {
query.Limit = l
}
}
if offset := urlValues.Get("offset"); offset != "" {
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
query.Offset = o
}
}
// Parse filters
filters, err := qp.parseFilters(urlValues)
if err != nil {
return query, err
}
query.Filters = filters
// Parse sorting
sorts, err := qp.parseSorting(urlValues)
if err != nil {
return query, err
}
query.Sort = sorts
return query, nil
}
// ParseQueryWithDefaultFields parses URL query parameters into a DynamicQuery struct with default fields
func (qp *QueryParserImpl) ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
query, err := qp.ParseQuery(values, defaultTable)
if err != nil {
return query, err
}
// If no fields specified, use default fields
if len(query.Fields) == 0 || (len(query.Fields) == 1 && query.Fields[0].Expression == "*") {
query.Fields = make([]SelectField, len(defaultFields))
for i, field := range defaultFields {
query.Fields[i] = SelectField{Expression: field}
}
}
return query, nil
}
// parseFilters parses filter parameters from URL values
func (qp *QueryParserImpl) parseFilters(values url.Values) ([]FilterGroup, error) {
filterMap := make(map[string]map[string]string)
for key, vals := range values {
if strings.HasPrefix(key, "filter[") && strings.HasSuffix(key, "]") {
parts := strings.Split(key[7:len(key)-1], "][")
if len(parts) == 2 {
column, operator := parts[0], parts[1]
if filterMap[column] == nil {
filterMap[column] = make(map[string]string)
}
if len(vals) > 0 {
filterMap[column][operator] = vals[0]
}
}
}
}
if len(filterMap) == 0 {
return nil, nil
}
var filters []DynamicFilter
for column, operators := range filterMap {
for opStr, value := range operators {
operator := FilterOperator(opStr)
var parsedValue interface{}
switch operator {
case OpIn, OpNotIn:
if value != "" {
parsedValue = strings.Split(value, ",")
}
case OpBetween, OpNotBetween:
if value != "" {
parts := strings.Split(value, ",")
if len(parts) == 2 {
parsedValue = []interface{}{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])}
}
}
case OpNull, OpNotNull:
parsedValue = nil
default:
parsedValue = value
}
filters = append(filters, DynamicFilter{Column: column, Operator: operator, Value: parsedValue})
}
}
if len(filters) == 0 {
return nil, nil
}
return []FilterGroup{{Filters: filters, LogicOp: "AND"}}, nil
}
// parseSorting parses sorting parameters from URL values
func (qp *QueryParserImpl) parseSorting(values url.Values) ([]SortField, error) {
sortParam := values.Get("sort")
if sortParam == "" {
return nil, nil
}
var sorts []SortField
fields := strings.Split(sortParam, ",")
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
order, column := "ASC", field
if strings.HasPrefix(field, "-") {
order = "DESC"
column = field[1:]
} else if strings.HasPrefix(field, "+") {
column = field[1:]
}
sorts = append(sorts, SortField{Column: column, Order: order})
}
return sorts, nil
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
package query
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSQLQueryBuilder_BuildWhereClause(t *testing.T) {
// Menggunakan PostgreSQL sebagai dialek pengujian
builder := NewSQLQueryBuilder(DBTypePostgreSQL)
allowedColumns := []string{
"age",
"status",
"category",
"price",
"is_featured",
"id",
"deleted_at",
"updated_at",
"created_at", // Diperlukan untuk perbandingan kolom-ke-kolom
"name", // Untuk test LIKE
"rating", // Untuk test BETWEEN
}
// Daftarkan semua kolom yang digunakan dalam tes sebagai kolom yang diizinkan.
// Ini penting karena security check (validasi kolom) diaktifkan secara default.
builder.SetAllowedColumns(allowedColumns)
t.Run("should build single group with AND logic", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
{Column: "age", Operator: OpGreaterThan, Value: 30},
{Column: "status", Operator: OpEqual, Value: "active"},
},
LogicOp: "AND",
},
}
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("age" > ? AND "status" = ?)`, sql)
assert.Equal(t, []interface{}{30, "active"}, args)
})
t.Run("should build multiple groups with OR logic", func(t *testing.T) {
// Skenario ini menguji: (category = 'electronics' AND price < 1000) OR (is_featured = true)
filters := []FilterGroup{
{ // Grup 1
Filters: []DynamicFilter{
{Column: "category", Operator: OpEqual, Value: "electronics"},
{Column: "price", Operator: OpLessThan, Value: 1000},
},
LogicOp: "AND",
},
{ // Grup 2
Filters: []DynamicFilter{
{Column: "is_featured", Operator: OpEqual, Value: true},
},
},
}
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
// Memastikan logika OR antar grup berjalan dengan benar
assert.Equal(t, `("category" = ? AND "price" < ?) OR ("is_featured" = ?)`, sql)
assert.Equal(t, []interface{}{"electronics", 1000, true}, args)
})
t.Run("should handle IN operator with slice", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
{Column: "id", Operator: OpIn, Value: []interface{}{1, 2, 3}},
},
},
}
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("id" IN (?,?,?))`, sql)
assert.Equal(t, []interface{}{1, 2, 3}, args)
})
t.Run("should handle IS NULL operator", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
{Column: "deleted_at", Operator: OpNull, Value: nil},
},
},
}
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("deleted_at" IS NULL)`, sql)
assert.Nil(t, args)
})
t.Run("should handle column-to-column comparison", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
// Membandingkan kolom updated_at dengan kolom created_at
{Column: "updated_at", Operator: OpGreaterThan, Value: "users.created_at"},
},
},
}
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("updated_at" > "users"."created_at")`, sql)
assert.Nil(t, args, "Column-to-column comparison should not produce arguments")
})
t.Run("should handle LIKE and ILIKE operators", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
{Column: "name", Operator: OpLike, Value: "%John%"},
{Column: "category", Operator: OpILike, Value: "ELECTRONICS"},
},
LogicOp: "AND",
},
}
// Test with PostgreSQL which supports ILIKE natively
pgBuilder := NewSQLQueryBuilder(DBTypePostgreSQL).SetAllowedColumns(allowedColumns)
sql, args, err := pgBuilder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("name" LIKE ? AND "category" ILIKE ?)`, sql)
assert.Equal(t, []interface{}{"%John%", "ELECTRONICS"}, args)
// Test with MySQL which simulates ILIKE with LOWER()
mysqlBuilder := NewSQLQueryBuilder(DBTypeMySQL).SetAllowedColumns(allowedColumns)
sql, args, err = mysqlBuilder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, "(`name` LIKE ? AND LOWER(`category`) LIKE LOWER(?))", sql)
assert.Equal(t, []interface{}{"%John%", "ELECTRONICS"}, args)
})
t.Run("should handle BETWEEN operator", func(t *testing.T) {
filters := []FilterGroup{
{
Filters: []DynamicFilter{
{Column: "rating", Operator: OpBetween, Value: []interface{}{4.0, 5.0}},
},
},
}
// This test uses the original `builder` which is configured for PostgreSQL
sql, args, err := builder.BuildWhereClause(filters)
assert.NoError(t, err)
assert.Equal(t, `("rating" BETWEEN ? AND ?)`, sql)
assert.Equal(t, []interface{}{4.0, 5.0}, args)
})
}
+200
View File
@@ -0,0 +1,200 @@
package query
import (
"fmt"
"reflect"
"strings"
"github.com/Masterminds/squirrel"
)
// buildFilterCondition builds a single filter condition with dialect-specific logic
func isColumnReference(val string) bool {
if val == "" {
return false
}
// Remove backslash-escaped quotes
unquoted := strings.ReplaceAll(val, "\\\"", "")
// Split by dot
parts := strings.Split(unquoted, ".")
if len(parts) != 2 {
return false
}
// Both parts should be alphanumeric or quoted identifiers
validPart := func(s string) bool {
if s == "" {
return false
}
// Allow quoted parts: "Quoted"
if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
return true
}
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') {
return false
}
}
return true
}
if !validPart(parts[0]) || !validPart(parts[1]) {
return false
}
return true
}
func (qb *SQLQueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) {
column := qb.validateAndEscapeColumn(filter.Column)
if column == "" {
return "", nil, fmt.Errorf("invalid or disallowed column: %s", filter.Column)
}
// Handle column-to-column comparison if filter.Value is a column reference string
if valStr, ok := filter.Value.(string); ok && isColumnReference(valStr) {
escapedVal := qb.escapeColumnReference(valStr)
switch filter.Operator {
case OpEqual:
return fmt.Sprintf("%s = %s", column, escapedVal), nil, nil
case OpNotEqual:
return fmt.Sprintf("%s <> %s", column, escapedVal), nil, nil
case OpGreaterThan:
return fmt.Sprintf("%s > %s", column, escapedVal), nil, nil
case OpLessThan:
return fmt.Sprintf("%s < %s", column, escapedVal), nil, nil
}
}
// Handle JSON operations
switch filter.Operator {
case OpJsonContains, OpJsonNotContains, OpJsonExists, OpJsonNotExists, OpJsonEqual, OpJsonNotEqual:
return qb.dialect.BuildJsonFilter(column, filter)
case OpArrayContains, OpArrayNotContains, OpArrayLength:
return qb.dialect.BuildArrayFilter(column, filter)
}
// Handle standard operators
switch filter.Operator {
case OpEqual:
if filter.Value == nil {
return fmt.Sprintf("%s IS NULL", column), nil, nil
}
return fmt.Sprintf("%s = ?", column), []interface{}{filter.Value}, nil
case OpNotEqual:
if filter.Value == nil {
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
}
return fmt.Sprintf("%s <> ?", column), []interface{}{filter.Value}, nil
case OpLike:
if filter.Value == nil {
return "", nil, nil
}
return fmt.Sprintf("%s LIKE ?", column), []interface{}{filter.Value}, nil
case OpILike:
if filter.Value == nil {
return "", nil, nil
}
return qb.dialect.BuildCaseInsensitiveLike(column), []interface{}{filter.Value}, nil
case OpIn, OpNotIn:
values := qb.parseArrayValue(filter.Value)
if len(values) == 0 {
return "1=0", nil, nil
}
op := "IN"
if filter.Operator == OpNotIn {
op = "NOT IN"
}
placeholders := squirrel.Placeholders(len(values))
return fmt.Sprintf("%s %s (%s)", column, op, placeholders), values, nil
case OpGreaterThan, OpGreaterThanEqual, OpLessThan, OpLessThanEqual:
if filter.Value == nil {
return "", nil, nil
}
opMap := map[FilterOperator]string{
OpGreaterThan: ">",
OpGreaterThanEqual: ">=",
OpLessThan: "<",
OpLessThanEqual: "<=",
}
op, ok := opMap[filter.Operator]
if !ok {
return "", nil, fmt.Errorf("unhandled comparison operator: %s", filter.Operator)
}
return fmt.Sprintf("%s %s ?", column, op), []interface{}{filter.Value}, nil
case OpBetween, OpNotBetween:
values := qb.parseArrayValue(filter.Value)
if len(values) != 2 {
return "", nil, fmt.Errorf("between operator requires exactly 2 values")
}
op := "BETWEEN"
if filter.Operator == OpNotBetween {
op = "NOT BETWEEN"
}
return fmt.Sprintf("%s %s ? AND ?", column, op), []interface{}{values[0], values[1]}, nil
case OpNull:
return fmt.Sprintf("%s IS NULL", column), nil, nil
case OpNotNull:
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
case OpContains, OpNotContains, OpStartsWith, OpEndsWith:
if filter.Value == nil {
return "", nil, nil
}
var value string
switch filter.Operator {
case OpContains, OpNotContains:
value = fmt.Sprintf("%%%v%%", filter.Value)
case OpStartsWith:
value = fmt.Sprintf("%v%%", filter.Value)
case OpEndsWith:
value = fmt.Sprintf("%%%v", filter.Value)
}
switch qb.dbType {
case DBTypePostgreSQL, DBTypeSQLite:
op := "ILIKE"
if strings.Contains(string(filter.Operator), "Not") {
op = "NOT ILIKE"
}
return fmt.Sprintf("%s %s ?", column, op), []interface{}{value}, nil
case DBTypeMySQL, DBTypeSQLServer:
op := "LIKE"
if strings.Contains(string(filter.Operator), "Not") {
op = "NOT LIKE"
}
return fmt.Sprintf("LOWER(%s) %s LOWER(?)", column, op), []interface{}{value}, nil
default:
op := "LIKE"
if strings.Contains(string(filter.Operator), "Not") {
op = "NOT LIKE"
}
return fmt.Sprintf("%s %s ?", column, op), []interface{}{value}, nil
}
default:
return "", nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
}
}
// parseArrayValue parses an array value for SQL queries
func (qb *SQLQueryBuilder) parseArrayValue(value interface{}) []interface{} {
if value == nil {
return nil
}
if reflect.TypeOf(value).Kind() == reflect.Slice {
v := reflect.ValueOf(value)
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
return result
}
if str, ok := value.(string); ok {
if strings.Contains(str, ",") {
parts := strings.Split(str, ",")
result := make([]interface{}, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
return []interface{}{str}
}
return []interface{}{value}
}
+227
View File
@@ -0,0 +1,227 @@
package query
import (
"fmt"
"regexp"
"strings"
"github.com/xwb1989/sqlparser"
)
// validateAndEscapeColumn validates and escapes a column name
func (qb *SQLQueryBuilder) validateAndEscapeColumn(field string) string {
if field == "" {
return ""
}
// Allow complex expressions like functions
if strings.Contains(field, "(") {
if qb.isValidExpression(field) {
return field // Don't escape complex expressions, assume they are safe
}
return ""
}
// For simple column names (not containing a dot), check against the allow list.
// For dotted names (table.column), we assume the check is not needed or handled elsewhere.
// The dialect's EscapeIdentifier will handle splitting and escaping each part of a dotted name.
if !strings.Contains(field, ".") {
if qb.allowedColumns != nil && !qb.allowedColumns[field] {
// Log or handle disallowed column
return ""
}
}
// Delegate the actual escaping to the dialect, which handles simple and dotted names correctly.
return qb.dialect.EscapeIdentifier(field)
}
// PERBAIKAN AKHIR: Sanitasi SQL penuh untuk kompatibilitas parser MySQL
func (qb *SQLQueryBuilder) validateParsedSQL(sql string) error {
// --- Langkah 1: Sanitasi untuk membuat SQL kompatibel dengan parser MySQL ---
// PERBAIKAN 1: Ganti quote PostgreSQL (") dengan quote MySQL (`)
sanitizedSQL := strings.ReplaceAll(sql, `"`, "`")
// PERBAIKAN 2: Ganti placeholder PostgreSQL ($1, $2, ...) dengan placeholder MySQL (?)
// Regex ini mencocokan '$' diikuti oleh satu atau lebih digit.
re := regexp.MustCompile(`\$\d+`)
sanitizedSQL = re.ReplaceAllString(sanitizedSQL, "?")
// PERBAIKAN 3: Ganti ILIKE dengan LIKE karena parser MySQL tidak mengenali ILIKE
reILike := regexp.MustCompile(`(?i)\bilike\b`)
sanitizedSQL = reILike.ReplaceAllString(sanitizedSQL, "LIKE")
// --- Langkah 2: Parse SQL yang sudah disanitasi ---
stmt, err := sqlparser.Parse(sanitizedSQL)
if err != nil {
// Jika SQL tidak valid (bahkan untuk parser MySQL), kita anggap ini tidak aman.
return fmt.Errorf("invalid SQL syntax detected during validation: %w", err)
}
// --- Langkah 3: Validasi struktur query (logikanya tetap sama) ---
switch stmt.(type) {
case *sqlparser.Select:
// Izinkan statement SELECT sederhana.
return nil // Aman
case *sqlparser.Union:
// Tolak statement UNION.
return fmt.Errorf("UNION statements are not allowed in this context")
case *sqlparser.Insert, *sqlparser.Update, *sqlparser.Delete:
// Tolak statement DML.
return fmt.Errorf("DML statements (INSERT/UPDATE/DELETE) are not allowed from user input")
case *sqlparser.DDL:
// Tolak statement DDL.
return fmt.Errorf("DDL statements are not allowed from user input")
default:
// Blokir statement lain.
return fmt.Errorf("unsupported or disallowed SQL statement type: %T", stmt)
}
}
func (qb *SQLQueryBuilder) isValidExpression(expr string) bool {
// This is a simplified check. A more robust solution might use a proper SQL parser library.
// For now, we allow alphanumeric, underscore, dots, parentheses, and common operators.
// For SQL Server, allow brackets [] and spaces for column names.
allowedChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.,() *-/[]"
for _, r := range expr {
if !strings.ContainsRune(allowedChars, r) {
return false
}
}
// PERBAIKAN: Gunakan Regex dengan word boundary untuk menghindari false positive
// Ini akan mencegah "DeletedAt" dianggap sebagai "delete"
dangerousPatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(--|/\*|\*/)`), // SQL comments
regexp.MustCompile(`(?i)\bunion\b\s+\bselect\b`), // UNION followed by SELECT
regexp.MustCompile(`(?i)\bselect\b`), // Standalone SELECT (bisa disesuaikan)
regexp.MustCompile(`(?i)\binsert\b`), // Standalone INSERT
regexp.MustCompile(`(?i)\bupdate\b`), // Standalone UPDATE
regexp.MustCompile(`(?i)\bdelete\b`), // Standalone DELETE
regexp.MustCompile(`(?i)\bdrop\b`), // DROP
regexp.MustCompile(`(?i)\balter\b`), // ALTER
regexp.MustCompile(`(?i)\bcreate\b`), // CREATE
regexp.MustCompile(`(?i)\b(exec|execute)\s*\(`), // EXEC/EXECUTE functions
regexp.MustCompile(`(?i)\b(waitfor\s+delay|benchmark|sleep)\s*\(`), // Time-based attacks
regexp.MustCompile(`(?i)\b(information_schema|sysobjects|syscolumns)\b`), // DB enumeration
}
lowerExpr := strings.ToLower(expr)
for _, pattern := range dangerousPatterns {
if pattern.MatchString(lowerExpr) {
// Log untuk debugging, jadi kita tahu pola mana yang cocok
// fmt.Printf("[DEBUG] Potentially dangerous expression detected: %s matched by pattern %s\n", expr, pattern.String())
return false
}
}
return true
}
// isValidFunctionName validates if a function name is valid
func (qb *SQLQueryBuilder) isValidFunctionName(name string) bool {
// Check if the function name is a valid SQL function
validFunctions := map[string]bool{
// Aggregate functions
"count": true, "sum": true, "avg": true, "min": true, "max": true,
// Window functions
"row_number": true, "rank": true, "dense_rank": true, "ntile": true,
"lag": true, "lead": true, "first_value": true, "last_value": true,
// JSON functions
"json_extract": true, "json_contains": true, "json_search": true,
"json_array": true, "json_object": true, "json_merge": true,
// Other functions
"concat": true, "substring": true, "upper": true, "lower": true,
"trim": true, "coalesce": true, "nullif": true, "isnull": true,
}
return validFunctions[strings.ToLower(name)]
}
// escapeColumnReference escapes a column reference (table.column)
func (qb *SQLQueryBuilder) escapeColumnReference(col string) string {
return qb.dialect.EscapeIdentifier(col)
}
// escapeJsonPath escapes a JSON path for PostgreSQL
func (qb *SQLQueryBuilder) escapeJsonPath(path string) string {
// Simple implementation - in a real scenario, you'd need more sophisticated escaping
return "'" + strings.ReplaceAll(path, "'", "''") + "'"
}
// escapeSqlServerJsonPath escapes a JSON path for SQL Server
func (qb *SQLQueryBuilder) escapeSqlServerJsonPath(path string) string {
// Convert JSONPath to SQL Server format
// $.path.to.property -> '$.path.to.property'
if !strings.HasPrefix(path, "$") {
path = "$." + path
}
return strings.ReplaceAll(path, ".", ".")
}
// checkForSqlInjectionInArgs checks for potential SQL injection patterns in query arguments
func (qb *SQLQueryBuilder) checkForSqlInjectionInArgs(args []interface{}) error {
if !qb.enableSecurityChecks {
return nil
}
for _, arg := range args {
if str, ok := arg.(string); ok {
lowerStr := strings.ToLower(str)
// Check for dangerous patterns specifically in user input values
dangerousPatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(union\s+select)`),
regexp.MustCompile(`(?i)(or\s+1\s*=\s*1)`),
regexp.MustCompile(`(?i)(and\s+true)`),
regexp.MustCompile(`(?i)(waitfor\s+delay)`),
regexp.MustCompile(`(?i)(benchmark|sleep)\s*\(`),
regexp.MustCompile(`(?i)(pg_sleep)\s*\(`),
regexp.MustCompile(`(?i)(load_file|into\s+outfile)`),
regexp.MustCompile(`(?i)(information_schema|sysobjects|syscolumns)`),
regexp.MustCompile(`(?i)(--|\/\*|\*\/)`),
}
for _, pattern := range dangerousPatterns {
if pattern.MatchString(lowerStr) {
return fmt.Errorf("potential SQL injection detected in query argument: pattern %s matched", pattern.String())
}
}
}
}
return nil
}
// checkForSqlInjectionInSQL checks for potential SQL injection patterns in the final SQL
func (qb *SQLQueryBuilder) checkForSqlInjectionInSQL(sql string) error {
if !qb.enableSecurityChecks {
return nil
}
// Check for dangerous patterns in the final SQL
// But allow valid SQL keywords in their proper context
lowerSQL := strings.ToLower(sql)
// More specific patterns that actually indicate injection attempts
dangerousPatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(union\s+select)`), // UNION followed by SELECT
regexp.MustCompile(`(?i)(select\s+.*\s+from\s+.*\s+where\s+.*\s+or\s+1\s*=\s*1)`), // Classic SQL injection
regexp.MustCompile(`(?i)(drop\s+table)`), // DROP TABLE
regexp.MustCompile(`(?i)(delete\s+from)`), // DELETE FROM
regexp.MustCompile(`(?i)(insert\s+into)`), // INSERT INTO
regexp.MustCompile(`(?i)(update\s+.*\s+set)`), // UPDATE SET
regexp.MustCompile(`(?i)(alter\s+table)`), // ALTER TABLE
regexp.MustCompile(`(?i)(create\s+table)`), // CREATE TABLE
regexp.MustCompile(`(?i)(exec\s*\(|execute\s*\()`), // EXEC/EXECUTE functions
regexp.MustCompile(`(?i)(--|\/\*|\*\/)`), // SQL comments
}
for _, pattern := range dangerousPatterns {
if pattern.MatchString(lowerSQL) {
return fmt.Errorf("potential SQL injection detected in SQL: pattern %s matched", pattern.String())
}
}
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package query
import (
"context"
"database/sql"
"time"
"github.com/jmoiron/sqlx"
"go.mongodb.org/mongo-driver/mongo"
)
// DBType represents the type of database
type DBType string
const (
DBTypePostgreSQL DBType = "postgres"
DBTypeMySQL DBType = "mysql"
DBTypeSQLite DBType = "sqlite"
DBTypeSQLServer DBType = "sqlserver"
DBTypeMongoDB DBType = "mongodb"
)
// FilterOperator represents supported filter operators
type FilterOperator string
const (
OpEqual FilterOperator = "_eq"
OpNotEqual FilterOperator = "_neq"
OpLike FilterOperator = "_like"
OpILike FilterOperator = "_ilike"
OpNotLike FilterOperator = "_nlike"
OpNotILike FilterOperator = "_nilike"
OpIn FilterOperator = "_in"
OpNotIn FilterOperator = "_nin"
OpGreaterThan FilterOperator = "_gt"
OpGreaterThanEqual FilterOperator = "_gte"
OpLessThan FilterOperator = "_lt"
OpLessThanEqual FilterOperator = "_lte"
OpBetween FilterOperator = "_between"
OpNotBetween FilterOperator = "_nbetween"
OpNull FilterOperator = "_null"
OpNotNull FilterOperator = "_nnull"
OpContains FilterOperator = "_contains"
OpNotContains FilterOperator = "_ncontains"
OpStartsWith FilterOperator = "_starts_with"
OpEndsWith FilterOperator = "_ends_with"
OpJsonContains FilterOperator = "_json_contains"
OpJsonNotContains FilterOperator = "_json_ncontains"
OpJsonExists FilterOperator = "_json_exists"
OpJsonNotExists FilterOperator = "_json_nexists"
OpJsonEqual FilterOperator = "_json_eq"
OpJsonNotEqual FilterOperator = "_json_neq"
OpArrayContains FilterOperator = "_array_contains"
OpArrayNotContains FilterOperator = "_array_ncontains"
OpArrayLength FilterOperator = "_array_length"
)
// DynamicFilter represents a single filter condition
type DynamicFilter struct {
Column string `json:"column"`
Operator FilterOperator `json:"operator"`
Value interface{} `json:"value"`
// Additional options for complex filters
Options map[string]interface{} `json:"options,omitempty"`
}
// FilterGroup represents a group of filters with a logical operator (AND/OR)
type FilterGroup struct {
Filters []DynamicFilter `json:"filters"`
LogicOp string `json:"logic_op"` // AND, OR
}
// SelectField represents a field in the SELECT clause, supporting expressions and aliases
type SelectField struct {
Expression string `json:"expression"` // e.g., "TMLogBarang.Nama", "COUNT(*)"
Alias string `json:"alias"` // e.g., "obat_nama", "total_count"
// Window function support
WindowFunction *WindowFunction `json:"window_function,omitempty"`
}
// WindowFunction represents a window function with its configuration
type WindowFunction struct {
Function string `json:"function"` // e.g., "ROW_NUMBER", "RANK", "DENSE_RANK", "LEAD", "LAG"
Over string `json:"over"` // PARTITION BY expression
OrderBy string `json:"order_by"` // ORDER BY expression
Frame string `json:"frame"` // ROWS/RANGE clause
Alias string `json:"alias"` // Alias for the window function
}
// Join represents a JOIN clause
type Join struct {
Type string `json:"type"` // "INNER", "LEFT", "RIGHT", "FULL"
Table string `json:"table"` // Table name to join
Alias string `json:"alias"` // Table alias
OnConditions FilterGroup `json:"on_conditions"` // Conditions for the ON clause
// LATERAL JOIN support
Lateral bool `json:"lateral,omitempty"`
}
// Union represents a UNION clause
type Union struct {
Type string `json:"type"` // "UNION", "UNION ALL"
Query DynamicQuery `json:"query"` // The subquery to union with
}
// CTE (Common Table Expression) represents a WITH clause
type CTE struct {
Name string `json:"name"` // CTE alias name
Query DynamicQuery `json:"query"` // The query defining the CTE
// Recursive CTE support
Recursive bool `json:"recursive,omitempty"`
}
// DynamicQuery represents the complete query structure
type DynamicQuery struct {
Fields []SelectField `json:"fields,omitempty"`
From string `json:"from"` // Main table name
Aliases string `json:"aliases"` // Main table alias
Joins []Join `json:"joins,omitempty"`
Filters []FilterGroup `json:"filters,omitempty"`
GroupBy []string `json:"group_by,omitempty"`
Having []FilterGroup `json:"having,omitempty"`
Unions []Union `json:"unions,omitempty"`
CTEs []CTE `json:"ctes,omitempty"`
Sort []SortField `json:"sort,omitempty"`
Limit int `json:"limit"`
Offset int `json:"offset"`
// Window function support
WindowFunctions []WindowFunction `json:"window_functions,omitempty"`
// JSON operations
JsonOperations []JsonOperation `json:"json_operations,omitempty"`
}
// JsonOperation represents a JSON operation
type JsonOperation struct {
Type string `json:"type"` // "extract", "exists", "contains", etc.
Column string `json:"column"` // JSON column
Path string `json:"path"` // JSON path
Value interface{} `json:"value,omitempty"` // Value for comparison
Alias string `json:"alias,omitempty"` // Alias for the result
}
// SortField represents sorting configuration
type SortField struct {
Column string `json:"column"`
Order string `json:"order"` // ASC, DESC
}
// UpdateData represents data for UPDATE operations
type UpdateData struct {
Columns []string `json:"columns"`
Values []interface{} `json:"values"`
// JSON update support
JsonUpdates map[string]JsonUpdate `json:"json_updates,omitempty"`
}
// JsonUpdate represents a JSON update operation
type JsonUpdate struct {
Path string `json:"path"` // JSON path
Value interface{} `json:"value"` // New value
}
// InsertData represents data for INSERT operations
type InsertData struct {
Columns []string `json:"columns"`
Values []interface{} `json:"values"`
// JSON insert support
JsonValues map[string]interface{} `json:"json_values,omitempty"`
}
// QueryBuilder interface defines the contract for query builders
type QueryBuilder interface {
// Configuration methods
SetSecurityOptions(enableChecks bool, maxRows int) QueryBuilder
SetAllowedColumns(columns []string) QueryBuilder
SetAllowedTables(tables []string) QueryBuilder
SetQueryLogging(enable bool) QueryBuilder
SetQueryTimeout(timeout time.Duration) QueryBuilder
// SQL building methods
BuildQuery(query DynamicQuery) (string, []interface{}, error)
BuildCountQuery(query DynamicQuery) (string, []interface{}, error)
BuildInsertQuery(table string, data InsertData, returningColumns ...string) (string, []interface{}, error)
BuildUpdateQuery(table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error)
BuildDeleteQuery(table string, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error)
BuildUpsertQuery(table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (string, []interface{}, error)
BuildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error)
// SQL execution methods
ExecuteQuery(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error
ExecuteQueryRow(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error
ExecuteCount(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery) (int64, error)
ExecuteInsert(ctx context.Context, exec sqlx.ExtContext, table string, data InsertData, returningColumns ...string) (sql.Result, error)
ExecuteUpdate(ctx context.Context, exec sqlx.ExtContext, table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (sql.Result, error)
ExecuteDelete(ctx context.Context, exec sqlx.ExtContext, table string, filters []FilterGroup, returningColumns ...string) (sql.Result, error)
ExecuteUpsert(ctx context.Context, exec sqlx.ExtContext, table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (sql.Result, error)
}
// MongoQueryBuilder interface defines the contract for MongoDB query builders
type MongoQueryBuilder interface {
// Configuration methods
SetSecurityOptions(enableChecks bool, maxDocs int) MongoQueryBuilder
SetAllowedFields(fields []string) MongoQueryBuilder
SetAllowedCollections(collections []string) MongoQueryBuilder
SetQueryLogging(enable bool) MongoQueryBuilder
SetQueryTimeout(timeout time.Duration) MongoQueryBuilder
// MongoDB building methods
BuildFindQuery(query DynamicQuery) (interface{}, interface{}, error)
BuildAggregateQuery(query DynamicQuery) (interface{}, error)
// MongoDB execution methods
ExecuteFind(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error
ExecuteAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error
ExecuteCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error)
ExecuteInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error)
ExecuteUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error)
ExecuteDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error)
}
// QueryParser interface defines the contract for query parsers
type QueryParser interface {
SetLimits(defaultLimit, maxLimit int) QueryParser
ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error)
ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error)
}
+40
View File
@@ -0,0 +1,40 @@
package validator
import (
"errors"
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
// TranslateError menerjemahkan error dari go-playground/validator menjadi pesan bahasa Indonesia.
func TranslateError(err error) string {
var ve validator.ValidationErrors
// Cek apakah error merupakan tipe ValidationErrors dari go-playground
if errors.As(err, &ve) {
var errMsgs []string
for _, fe := range ve {
// fe.Field() mengambil nama field struct (atau bisa pakai fe.Param() untuk parameter)
switch fe.Tag() {
case "required":
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' wajib diisi", fe.Field()))
case "oneof":
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' harus bernilai salah satu dari: %s", fe.Field(), fe.Param()))
case "min":
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' minimal harus %s karakter", fe.Field(), fe.Param()))
case "max":
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' maksimal harus %s karakter", fe.Field(), fe.Param()))
case "email":
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' harus berupa format email yang valid", fe.Field()))
default:
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' tidak valid pada validasi '%s'", fe.Field(), fe.Tag()))
}
}
return strings.Join(errMsgs, ", ")
}
// Kembalikan error asli jika bukan error validasi
return err.Error()
}