first commit
This commit is contained in:
@@ -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...
|
||||
}
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user