From d4638dddc8f00189906df769c8b8985a86e15217 Mon Sep 17 00:00:00 2001 From: Meninjar Date: Thu, 23 Oct 2025 05:37:33 +0700 Subject: [PATCH] update builder --- internal/handlers/retribusi/sqruel | 1611 ++++++++++++++++++++++ internal/utils/filters/dynamic_filter.go | 455 ++++-- internal/utils/query/builder.go | 1139 +++++++++++++++ internal/utils/query/exemple.go | 220 +++ 4 files changed, 3329 insertions(+), 96 deletions(-) create mode 100644 internal/handlers/retribusi/sqruel create mode 100644 internal/utils/query/builder.go create mode 100644 internal/utils/query/exemple.go diff --git a/internal/handlers/retribusi/sqruel b/internal/handlers/retribusi/sqruel new file mode 100644 index 00000000..e96b76ac --- /dev/null +++ b/internal/handlers/retribusi/sqruel @@ -0,0 +1,1611 @@ +package handlers + +import ( + "api-service/internal/config" + "api-service/internal/database" + models "api-service/internal/models" + "api-service/internal/models/retribusi" + utils "api-service/internal/utils/filters" + "api-service/internal/utils/validation" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/Masterminds/squirrel" // Menambahkan library query builder + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +// Initialize the database connection and validator +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validateRetribusiStatus) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +// Custom validation for retribusi status +func validateRetribusiStatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +// RetribusiHandler handles retribusi services +type RetribusiHandler struct { + db database.Service + builder squirrel.StatementBuilderType // Menambahkan query builder +} + +// NewRetribusiHandler creates a new RetribusiHandler +func NewRetribusiHandler() *RetribusiHandler { + return &RetribusiHandler{ + db: db, + builder: squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar), // Menggunakan placeholder PostgreSQL + } +} + +// GetRetribusi godoc +// @Summary Get retribusi with pagination and optional aggregation +// @Description Returns a paginated list of retribusis with optional summary statistics +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param limit query int false "Limit (max 100)" default(10) +// @Param offset query int false "Offset" default(0) +// @Param include_summary query bool false "Include aggregation summary" default(false) +// @Param status query string false "Filter by status" +// @Param jenis query string false "Filter by jenis" +// @Param dinas query string false "Filter by dinas" +// @Param search query string false "Search in multiple fields" +// @Success 200 {object} retribusi.RetribusiGetResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Bad request" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusis [get] +func (h *RetribusiHandler) GetRetribusi(c *gin.Context) { + // Parse pagination parameters + limit, offset, err := h.parsePaginationParams(c) + if err != nil { + h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) + return + } + + // Parse filter parameters + filter := h.parseFilterParams(c) + includeAggregation := c.Query("include_summary") == "true" + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute concurrent operations + var ( + retribusis []retribusi.Retribusi + total int + aggregateData *models.AggregateData + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { + mu.Lock() + errChan <- fmt.Errorf("failed to get total count: %w", err) + mu.Unlock() + } + }() + + // Fetch main data + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.fetchRetribusis(ctx, dbConn, filter, limit, offset) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to fetch data: %w", err) + } else { + retribusis = result + } + mu.Unlock() + }() + + // Fetch aggregation data if requested + if includeAggregation { + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.getAggregateData(ctx, dbConn, filter) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to get aggregate data: %w", err) + } else { + aggregateData = result + } + mu.Unlock() + }() + } + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) + return + } + } + + // Build response + meta := h.calculateMeta(limit, offset, total) + response := retribusi.RetribusiGetResponse{ + Message: "Data retribusi berhasil diambil", + Data: retribusis, + Meta: meta, + } + + if includeAggregation && aggregateData != nil { + response.Summary = aggregateData + } + + c.JSON(http.StatusOK, response) +} + +// GetRetribusiByID godoc +// @Summary Get Retribusi by ID +// @Description Returns a single retribusi by ID +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param id path string true "Retribusi ID (UUID)" +// @Success 200 {object} retribusi.RetribusiGetByIDResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Retribusi not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusi/{id} [get] +func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + dataretribusi, err := h.getRetribusiByID(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to get retribusi", err, http.StatusInternalServerError) + } + return + } + + response := retribusi.RetribusiGetByIDResponse{ + Message: "Retribusi details retrieved successfully", + Data: dataretribusi, + } + + c.JSON(http.StatusOK, response) +} + +// GetRetribusiDynamic godoc +// @Summary Get retribusi with dynamic filtering +// @Description Returns retribusis with advanced dynamic filtering like Directus +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param fields query string false "Fields to select (e.g., fields=*.*)" +// @Param filter[column][operator] query string false "Dynamic filters (e.g., filter[Jenis][_eq]=value)" +// @Param sort query string false "Sort fields (e.g., sort=date_created,-Jenis)" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} retribusi.RetribusiGetResponse "Success response" +// @Failure 400 {object} models.ErrorResponse "Bad request" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusis/dynamic [get] +func (h *RetribusiHandler) GetRetribusiDynamic(c *gin.Context) { + // Parse query parameters + parser := utils.NewQueryParser().SetLimits(10, 100) + dynamicQuery, err := parser.ParseQuery(c.Request.URL.Query()) + if err != nil { + h.respondError(c, "Invalid query parameters", err, http.StatusBadRequest) + return + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute query with dynamic filtering + retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, dynamicQuery) + if err != nil { + h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, total) + response := retribusi.RetribusiGetResponse{ + Message: "Data retribusi berhasil diambil", + Data: retribusis, + Meta: meta, + } + + c.JSON(http.StatusOK, response) +} + +// fetchRetribusisDynamic executes dynamic query +func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *sql.DB, query utils.DynamicQuery) ([]retribusi.Retribusi, int, error) { + // Setup query builder + countBuilder := utils.NewQueryBuilder("data_retribusi"). + SetColumnMapping(map[string]string{ + "jenis": "Jenis", + "pelayanan": "Pelayanan", + "dinas": "Dinas", + "kelompok_obyek": "Kelompok_obyek", + "Kode_tarif": "Kode_tarif", + "kode_tarif": "Kode_tarif", + "tarif": "Tarif", + "satuan": "Satuan", + "tarif_overtime": "Tarif_overtime", + "satuan_overtime": "Satuan_overtime", + "rekening_pokok": "Rekening_pokok", + "rekening_denda": "Rekening_denda", + "uraian_1": "Uraian_1", + "uraian_2": "Uraian_2", + "uraian_3": "Uraian_3", + }). + SetAllowedColumns([]string{ + "id", "status", "sort", "user_created", "date_created", + "user_updated", "date_updated", "Jenis", "Pelayanan", + "Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan", + "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", + "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + }) + + mainBuilder := utils.NewQueryBuilder("data_retribusi"). + SetColumnMapping(map[string]string{ + "jenis": "Jenis", + "pelayanan": "Pelayanan", + "dinas": "Dinas", + "kelompok_obyek": "Kelompok_obyek", + "Kode_tarif": "Kode_tarif", + "kode_tarif": "Kode_tarif", + "tarif": "Tarif", + "satuan": "Satuan", + "tarif_overtime": "Tarif_overtime", + "satuan_overtime": "Satuan_overtime", + "rekening_pokok": "Rekening_pokok", + "rekening_denda": "Rekening_denda", + "uraian_1": "Uraian_1", + "uraian_2": "Uraian_2", + "uraian_3": "Uraian_3", + }). + SetAllowedColumns([]string{ + "id", "status", "sort", "user_created", "date_created", + "user_updated", "date_updated", "Jenis", "Pelayanan", + "Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan", + "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", + "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + }) + + // Add default filter to exclude deleted records + if len(query.Filters) > 0 { + query.Filters = append([]utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }}, + LogicOp: "AND", + }}, query.Filters...) + } else { + query.Filters = []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }}, + LogicOp: "AND", + }} + } + + // Execute queries sequentially to avoid race conditions + var total int + var retribusis []retribusi.Retribusi + + // 1. Get total count first + countQuery := query + countQuery.Limit = 0 + countQuery.Offset = 0 + + countSQL, countArgs, err := countBuilder.BuildCountQuery(countQuery) + if err != nil { + return nil, 0, fmt.Errorf("failed to build count query: %w", err) + } + + if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // 2. Get main data + mainSQL, mainArgs, err := mainBuilder.BuildQuery(query) + if err != nil { + return nil, 0, fmt.Errorf("failed to build main query: %w", err) + } + + rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...) + if err != nil { + return nil, 0, fmt.Errorf("failed to execute main query: %w", err) + } + defer rows.Close() + + for rows.Next() { + retribusi, err := h.scanRetribusi(rows) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan retribusi: %w", err) + } + retribusis = append(retribusis, retribusi) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("rows iteration error: %w", err) + } + + return retribusis, total, nil +} + +// SearchRetribusiAdvanced provides advanced search capabilities +func (h *RetribusiHandler) SearchRetribusiAdvanced(c *gin.Context) { + // Parse complex search parameters + searchQuery := c.Query("q") + if searchQuery == "" { + // If no search query provided, return all records with default sorting + query := utils.DynamicQuery{ + Fields: []string{"*"}, + Filters: []utils.FilterGroup{}, // Empty filters - fetchRetribusisDynamic will add default deleted filter + Sort: []utils.SortField{{ + Column: "date_created", + Order: "DESC", + }}, + Limit: 20, + Offset: 0, + } + + // Parse pagination if provided + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { + query.Limit = l + } + } + + if offset := c.Query("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute query to get all records + retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query) + if err != nil { + h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(query.Limit, query.Offset, total) + response := retribusi.RetribusiGetResponse{ + Message: "All records retrieved (no search query provided)", + Data: retribusis, + Meta: meta, + } + + c.JSON(http.StatusOK, response) + return + } + + // Build dynamic query for search + query := utils.DynamicQuery{ + Fields: []string{"*"}, + Filters: []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{ + { + Column: "Jenis", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Pelayanan", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Dinas", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Uraian_1", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + }, + LogicOp: "AND", + }}, + Sort: []utils.SortField{{ + Column: "date_created", + Order: "DESC", + }}, + Limit: 20, + Offset: 0, + } + + // Parse pagination if provided + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { + query.Limit = l + } + } + + if offset := c.Query("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute search + retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query) + if err != nil { + h.logAndRespondError(c, "Search failed", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(query.Limit, query.Offset, total) + response := retribusi.RetribusiGetResponse{ + Message: fmt.Sprintf("Search results for '%s'", searchQuery), + Data: retribusis, + Meta: meta, + } + + c.JSON(http.StatusOK, response) +} + +// CreateRetribusi godoc +// @Summary Create retribusi +// @Description Creates a new retribusi record +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param request body retribusi.RetribusiCreateRequest true "Retribusi creation request" +// @Success 201 {object} retribusi.RetribusiCreateResponse "Retribusi created successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusis [post] +func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) { + var req retribusi.RetribusiCreateRequest + + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + // Validate duplicate and daily submission + if err := h.validateRetribusiSubmission(ctx, dbConn, &req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dataretribusi, err := h.createRetribusi(ctx, dbConn, &req) + if err != nil { + h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError) + return + } + + response := retribusi.RetribusiCreateResponse{ + Message: "Retribusi berhasil dibuat", + Data: dataretribusi, + } + + c.JSON(http.StatusCreated, response) +} + +// UpdateRetribusi godoc +// @Summary Update retribusi +// @Description Updates an existing retribusi record +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param id path string true "Retribusi ID (UUID)" +// @Param request body retribusi.RetribusiUpdateRequest true "Retribusi update request" +// @Success 200 {object} retribusi.RetribusiUpdateResponse "Retribusi updated successfully" +// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} models.ErrorResponse "Retribusi not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusi/{id} [put] +func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + var req retribusi.RetribusiUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + + // Set ID from path parameter + req.ID = id + + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + dataretribusi, err := h.updateRetribusi(ctx, dbConn, &req) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to update retribusi", err, http.StatusInternalServerError) + } + return + } + + response := retribusi.RetribusiUpdateResponse{ + Message: "Retribusi berhasil diperbarui", + Data: dataretribusi, + } + + c.JSON(http.StatusOK, response) +} + +// DeleteRetribusi godoc +// @Summary Delete retribusi +// @Description Soft deletes a retribusi by setting status to 'deleted' +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param id path string true "Retribusi ID (UUID)" +// @Success 200 {object} retribusi.RetribusiDeleteResponse "Retribusi deleted successfully" +// @Failure 400 {object} models.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models.ErrorResponse "Retribusi not found" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusi/{id} [delete] +func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) { + id := c.Param("id") + + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } + + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + err = h.deleteRetribusi(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to delete retribusi", err, http.StatusInternalServerError) + } + return + } + + response := retribusi.RetribusiDeleteResponse{ + Message: "Retribusi berhasil dihapus", + ID: id, + } + + c.JSON(http.StatusOK, response) +} + +// GetRetribusiStats godoc +// @Summary Get retribusi statistics +// @Description Returns comprehensive statistics about retribusi data +// @Tags Retribusi +// @Accept json +// @Produce json +// @Param status query string false "Filter statistics by status" +// @Success 200 {object} models.AggregateData "Statistics data" +// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Router /api/v1/retribusis/stats [get] +func (h *RetribusiHandler) GetRetribusiStats(c *gin.Context) { + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + filter := h.parseFilterParams(c) + aggregateData, err := h.getAggregateData(ctx, dbConn, filter) + if err != nil { + h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Statistik retribusi berhasil diambil", + "data": aggregateData, + }) +} + +// Get retribusi by ID using query builder +func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, id string) (*retribusi.Retribusi, error) { + // Menggunakan query builder untuk membuat query SELECT + query := h.builder. + Select( + "id", "status", "sort", "user_created", "date_created", "user_updated", "date_updated", + "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", + "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", + "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + ). + From("data_retribusi"). + Where(squirrel.Eq{"id": id}). + Where(squirrel.NotEq{"status": "deleted"}) + + // Mendapatkan SQL dan argumen + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + row := dbConn.QueryRowContext(ctx, sql, args...) + + var retribusi retribusi.Retribusi + err = row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) + + if err != nil { + return nil, err + } + + return &retribusi, nil +} + +// Create retribusi using query builder +func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) (*retribusi.Retribusi, error) { + id := uuid.New().String() + now := time.Now() + + // Menggunakan query builder untuk membuat query INSERT + query := h.builder. + Insert("data_retribusi"). + Columns( + "id", "status", "date_created", "date_updated", + "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", + "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", + "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + ). + Values( + id, req.Status, now, now, + req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, + req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, + req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, + ). + Suffix("RETURNING " + + "id, status, sort, user_created, date_created, user_updated, date_updated, " + + "Jenis, Pelayanan, Dinas, Kelompok_obyek, Kode_tarif, " + + "Tarif, Satuan, Tarif_overtime, Satuan_overtime, " + + "Rekening_pokok, Rekening_denda, Uraian_1, Uraian_2, Uraian_3") + + // Mendapatkan SQL dan argumen + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + row := dbConn.QueryRowContext(ctx, sql, args...) + + var retribusi retribusi.Retribusi + err = row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) + + if err != nil { + return nil, fmt.Errorf("failed to create retribusi: %w", err) + } + + return &retribusi, nil +} + +// Update retribusi using query builder +func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiUpdateRequest) (*retribusi.Retribusi, error) { + now := time.Now() + + // Menggunakan query builder untuk membuat query UPDATE + query := h.builder. + Update("data_retribusi"). + Set("status", req.Status). + Set("date_updated", now). + Set("Jenis", req.Jenis). + Set("Pelayanan", req.Pelayanan). + Set("Dinas", req.Dinas). + Set("Kelompok_obyek", req.KelompokObyek). + Set("Kode_tarif", req.KodeTarif). + Set("Tarif", req.Tarif). + Set("Satuan", req.Satuan). + Set("Tarif_overtime", req.TarifOvertime). + Set("Satuan_overtime", req.SatuanOvertime). + Set("Rekening_pokok", req.RekeningPokok). + Set("Rekening_denda", req.RekeningDenda). + Set("Uraian_1", req.Uraian1). + Set("Uraian_2", req.Uraian2). + Set("Uraian_3", req.Uraian3). + Where(squirrel.Eq{"id": req.ID}). + Where(squirrel.NotEq{"status": "deleted"}). + Suffix("RETURNING " + + "id, status, sort, user_created, date_created, user_updated, date_updated, " + + "Jenis, Pelayanan, Dinas, Kelompok_obyek, Kode_tarif, " + + "Tarif, Satuan, Tarif_overtime, Satuan_overtime, " + + "Rekening_pokok, Rekening_denda, Uraian_1, Uraian_2, Uraian_3") + + // Mendapatkan SQL dan argumen + sql, args, err := query.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + row := dbConn.QueryRowContext(ctx, sql, args...) + + var retribusi retribusi.Retribusi + err = row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) + + if err != nil { + return nil, fmt.Errorf("failed to update retribusi: %w", err) + } + + return &retribusi, nil +} + +// Soft delete retribusi using query builder +func (h *RetribusiHandler) deleteRetribusi(ctx context.Context, dbConn *sql.DB, id string) error { + now := time.Now() + + // Menggunakan query builder untuk membuat query UPDATE + query := h.builder. + Update("data_retribusi"). + Set("status", "deleted"). + Set("date_updated", now). + Where(squirrel.Eq{"id": id}). + Where(squirrel.NotEq{"status": "deleted"}) + + // Mendapatkan SQL dan argumen + sql, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + result, err := dbConn.ExecContext(ctx, sql, args...) + if err != nil { + return fmt.Errorf("failed to delete retribusi: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +// Enhanced error handling +func (h *RetribusiHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { + logger.Error(message, map[string]interface{}{ + "error": err.Error(), + "status_code": statusCode, + }) + h.respondError(c, message, err, statusCode) +} + +func (h *RetribusiHandler) respondError(c *gin.Context, message string, err error, statusCode int) { + errorMessage := message + if gin.Mode() == gin.ReleaseMode { + errorMessage = "Internal server error" + } + + c.JSON(statusCode, models.ErrorResponse{ + Error: errorMessage, + Code: statusCode, + Message: err.Error(), + Timestamp: time.Now(), + }) +} + +// Parse pagination parameters dengan validation yang lebih ketat +func (h *RetribusiHandler) parsePaginationParams(c *gin.Context) (int, int, error) { + limit := 10 // Default limit + offset := 0 // Default offset + + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) + } + if parsedLimit <= 0 { + return 0, 0, fmt.Errorf("limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fmt.Errorf("limit cannot exceed 100") + } + limit = parsedLimit + } + + if offsetStr := c.Query("offset"); offsetStr != "" { + parsedOffset, err := strconv.Atoi(offsetStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) + } + if parsedOffset < 0 { + return 0, 0, fmt.Errorf("offset cannot be negative") + } + offset = parsedOffset + } + + logger.Debug("Pagination parameters", map[string]interface{}{ + "limit": limit, + "offset": offset, + }) + return limit, offset, nil +} + +// Build WHERE clause dengan filter parameters menggunakan query builder +func (h *RetribusiHandler) buildWhereClause(filter retribusi.RetribusiFilter) (string, []interface{}) { + // Menggunakan query builder untuk membuat WHERE clause + builder := h.builder.Select("count(*)").From("data_retribusi") + + // Filter default untuk mengecualikan record yang dihapus + builder = builder.Where(squirrel.NotEq{"status": "deleted"}) + + // Menambahkan filter berdasarkan parameter + if filter.Status != nil { + builder = builder.Where(squirrel.Eq{"status": *filter.Status}) + } + + if filter.Jenis != nil { + builder = builder.Where(squirrel.Like{"Jenis": "%" + *filter.Jenis + "%"}) + } + + if filter.Dinas != nil { + builder = builder.Where(squirrel.Like{"Dinas": "%" + *filter.Dinas + "%"}) + } + + if filter.KelompokObyek != nil { + builder = builder.Where(squirrel.Like{"Kelompok_obyek": "%" + *filter.KelompokObyek + "%"}) + } + + if filter.Search != nil { + searchTerm := "%" + *filter.Search + "%" + builder = builder.Where(squirrel.Or{ + squirrel.Like{"Jenis": searchTerm}, + squirrel.Like{"Pelayanan": searchTerm}, + squirrel.Like{"Dinas": searchTerm}, + squirrel.Like{"Kode_tarif": searchTerm}, + squirrel.Like{"Uraian_1": searchTerm}, + squirrel.Like{"Uraian_2": searchTerm}, + squirrel.Like{"Uraian_3": searchTerm}, + }) + } + + if filter.DateFrom != nil { + builder = builder.Where(squirrel.GtOrEq{"date_created": *filter.DateFrom}) + } + + if filter.DateTo != nil { + endOfDay := filter.DateTo.Add(24*time.Hour - time.Nanosecond) + builder = builder.Where(squirrel.LtOrEq{"date_created": endOfDay}) + } + + // Mendapatkan SQL dan argumen + sql, args, err := builder.ToSql() + if err != nil { + return "", nil + } + + // Menghapus prefix "SELECT count(*) FROM " dari SQL untuk mendapatkan WHERE clause saja + whereClause := strings.Replace(sql, "SELECT count(*) FROM data_retribusi ", "", 1) + + return whereClause, args +} + +// Optimized scanning function yang menggunakan sql.Null* types langsung +func (h *RetribusiHandler) scanRetribusi(rows *sql.Rows) (retribusi.Retribusi, error) { + var retribusi retribusi.Retribusi + + return retribusi, rows.Scan( + &retribusi.ID, + &retribusi.Status, + &retribusi.Sort, + &retribusi.UserCreated, + &retribusi.DateCreated, + &retribusi.UserUpdated, + &retribusi.DateUpdated, + &retribusi.Jenis, + &retribusi.Pelayanan, + &retribusi.Dinas, + &retribusi.KelompokObyek, + &retribusi.KodeTarif, + &retribusi.Tarif, + &retribusi.Satuan, + &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, + &retribusi.RekeningPokok, + &retribusi.RekeningDenda, + &retribusi.Uraian1, + &retribusi.Uraian2, + &retribusi.Uraian3, + ) +} + +// Parse filter parameters dari query string +func (h *RetribusiHandler) parseFilterParams(c *gin.Context) retribusi.RetribusiFilter { + filter := retribusi.RetribusiFilter{} + + if status := c.Query("status"); status != "" { + if models.IsValidStatus(status) { + filter.Status = &status + } + } + + if jenis := c.Query("jenis"); jenis != "" { + filter.Jenis = &jenis + } + + if dinas := c.Query("dinas"); dinas != "" { + filter.Dinas = &dinas + } + + if kelompokObyek := c.Query("kelompok_obyek"); kelompokObyek != "" { + filter.KelompokObyek = &kelompokObyek + } + + if search := c.Query("search"); search != "" { + filter.Search = &search + } + + // Parse date filters + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + filter.DateFrom = &dateFrom + } + } + + if dateToStr := c.Query("date_to"); dateToStr != "" { + if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + filter.DateTo = &dateTo + } + } + + return filter +} + +// Get comprehensive aggregate data dengan filter support menggunakan query builder +func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter) (*models.AggregateData, error) { + aggregate := &models.AggregateData{ + ByStatus: make(map[string]int), + ByDinas: make(map[string]int), + ByJenis: make(map[string]int), + } + + // Build base query dengan filter + baseBuilder := h.builder.Select("*").From("data_retribusi").Where(squirrel.NotEq{"status": "deleted"}) + + // Menambahkan filter berdasarkan parameter + if filter.Status != nil { + baseBuilder = baseBuilder.Where(squirrel.Eq{"status": *filter.Status}) + } + + if filter.Jenis != nil { + baseBuilder = baseBuilder.Where(squirrel.Like{"Jenis": "%" + *filter.Jenis + "%"}) + } + + if filter.Dinas != nil { + baseBuilder = baseBuilder.Where(squirrel.Like{"Dinas": "%" + *filter.Dinas + "%"}) + } + + if filter.KelompokObyek != nil { + baseBuilder = baseBuilder.Where(squirrel.Like{"Kelompok_obyek": "%" + *filter.KelompokObyek + "%"}) + } + + if filter.Search != nil { + searchTerm := "%" + *filter.Search + "%" + baseBuilder = baseBuilder.Where(squirrel.Or{ + squirrel.Like{"Jenis": searchTerm}, + squirrel.Like{"Pelayanan": searchTerm}, + squirrel.Like{"Dinas": searchTerm}, + squirrel.Like{"Kode_tarif": searchTerm}, + squirrel.Like{"Uraian_1": searchTerm}, + squirrel.Like{"Uraian_2": searchTerm}, + squirrel.Like{"Uraian_3": searchTerm}, + }) + } + + if filter.DateFrom != nil { + baseBuilder = baseBuilder.Where(squirrel.GtOrEq{"date_created": *filter.DateFrom}) + } + + if filter.DateTo != nil { + endOfDay := filter.DateTo.Add(24*time.Hour - time.Nanosecond) + baseBuilder = baseBuilder.Where(squirrel.LtOrEq{"date_created": endOfDay}) + } + + // Use concurrent execution untuk performance + var wg sync.WaitGroup + var mu sync.Mutex + errChan := make(chan error, 4) + + // 1. Count by status + wg.Add(1) + go func() { + defer wg.Done() + + // Build query untuk count by status + statusBuilder := baseBuilder. + Select("status", "COUNT(*)"). + GroupBy("status"). + OrderBy("status") + + sql, args, err := statusBuilder.ToSql() + if err != nil { + errChan <- fmt.Errorf("status query failed: %w", err) + return + } + + rows, err := dbConn.QueryContext(ctx, sql, args...) + if err != nil { + errChan <- fmt.Errorf("status query failed: %w", err) + return + } + defer rows.Close() + + mu.Lock() + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("status scan failed: %w", err) + return + } + aggregate.ByStatus[status] = count + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + mu.Unlock() + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("status iteration error: %w", err) + } + }() + + // 2. Count by Dinas + wg.Add(1) + go func() { + defer wg.Done() + + // Build query untuk count by dinas + dinasBuilder := baseBuilder. + Select("COALESCE(Dinas, 'Unknown') as dinas", "COUNT(*)"). + Where(squirrel.NotEq{"Dinas": nil}). + Where(squirrel.NotEq{"Dinas": ""}). + GroupBy("Dinas"). + OrderBy("COUNT(*) DESC"). + Limit(10) + + sql, args, err := dinasBuilder.ToSql() + if err != nil { + errChan <- fmt.Errorf("dinas query failed: %w", err) + return + } + + rows, err := dbConn.QueryContext(ctx, sql, args...) + if err != nil { + errChan <- fmt.Errorf("dinas query failed: %w", err) + return + } + defer rows.Close() + + mu.Lock() + for rows.Next() { + var dinas string + var count int + if err := rows.Scan(&dinas, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("dinas scan failed: %w", err) + return + } + aggregate.ByDinas[dinas] = count + } + mu.Unlock() + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("dinas iteration error: %w", err) + } + }() + + // 3. Count by Jenis + wg.Add(1) + go func() { + defer wg.Done() + + // Build query untuk count by jenis + jenisBuilder := baseBuilder. + Select("COALESCE(Jenis, 'Unknown') as jenis", "COUNT(*)"). + Where(squirrel.NotEq{"Jenis": nil}). + Where(squirrel.NotEq{"Jenis": ""}). + GroupBy("Jenis"). + OrderBy("COUNT(*) DESC"). + Limit(10) + + sql, args, err := jenisBuilder.ToSql() + if err != nil { + errChan <- fmt.Errorf("jenis query failed: %w", err) + return + } + + rows, err := dbConn.QueryContext(ctx, sql, args...) + if err != nil { + errChan <- fmt.Errorf("jenis query failed: %w", err) + return + } + defer rows.Close() + + mu.Lock() + for rows.Next() { + var jenis string + var count int + if err := rows.Scan(&jenis, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("jenis scan failed: %w", err) + return + } + aggregate.ByJenis[jenis] = count + } + mu.Unlock() + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("jenis iteration error: %w", err) + } + }() + + // 4. Get last updated time dan today statistics + wg.Add(1) + go func() { + defer wg.Done() + + // Last updated + lastUpdatedBuilder := baseBuilder. + Select("MAX(date_updated)"). + Where(squirrel.NotEq{"date_updated": nil}) + + sql, args, err := lastUpdatedBuilder.ToSql() + if err != nil { + errChan <- fmt.Errorf("last updated query failed: %w", err) + return + } + + var lastUpdated sql.NullTime + if err := dbConn.QueryRowContext(ctx, sql, args...).Scan(&lastUpdated); err != nil { + errChan <- fmt.Errorf("last updated query failed: %w", err) + return + } + + // Today statistics + today := time.Now().Format("2006-01-02") + todayStatsBuilder := baseBuilder. + Select( + "SUM(CASE WHEN DATE(date_created) = ? THEN 1 ELSE 0 END) as created_today", + "SUM(CASE WHEN DATE(date_updated) = ? AND DATE(date_created) != ? THEN 1 ELSE 0 END) as updated_today", + ). + Where(squirrel.GtOrEq{"date_created": today}). + Where(squirrel.LtOrEq{"date_created": today + " 23:59:59"}) + + sql, args, err = todayStatsBuilder.ToSql() + if err != nil { + errChan <- fmt.Errorf("today stats query failed: %w", err) + return + } + + var createdToday, updatedToday int + if err := dbConn.QueryRowContext(ctx, sql, args...).Scan(&createdToday, &updatedToday); err != nil { + errChan <- fmt.Errorf("today stats query failed: %w", err) + return + } + + mu.Lock() + if lastUpdated.Valid { + aggregate.LastUpdated = &lastUpdated.Time + } + aggregate.CreatedToday = createdToday + aggregate.UpdatedToday = updatedToday + mu.Unlock() + }() + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + return nil, err + } + } + + return aggregate, nil +} + +// Get total count dengan filter support menggunakan query builder +func (h *RetribusiHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter, total *int) error { + // Build query untuk count + builder := h.builder.Select("COUNT(*)").From("data_retribusi").Where(squirrel.NotEq{"status": "deleted"}) + + // Menambahkan filter berdasarkan parameter + if filter.Status != nil { + builder = builder.Where(squirrel.Eq{"status": *filter.Status}) + } + + if filter.Jenis != nil { + builder = builder.Where(squirrel.Like{"Jenis": "%" + *filter.Jenis + "%"}) + } + + if filter.Dinas != nil { + builder = builder.Where(squirrel.Like{"Dinas": "%" + *filter.Dinas + "%"}) + } + + if filter.KelompokObyek != nil { + builder = builder.Where(squirrel.Like{"Kelompok_obyek": "%" + *filter.KelompokObyek + "%"}) + } + + if filter.Search != nil { + searchTerm := "%" + *filter.Search + "%" + builder = builder.Where(squirrel.Or{ + squirrel.Like{"Jenis": searchTerm}, + squirrel.Like{"Pelayanan": searchTerm}, + squirrel.Like{"Dinas": searchTerm}, + squirrel.Like{"Kode_tarif": searchTerm}, + squirrel.Like{"Uraian_1": searchTerm}, + squirrel.Like{"Uraian_2": searchTerm}, + squirrel.Like{"Uraian_3": searchTerm}, + }) + } + + if filter.DateFrom != nil { + builder = builder.Where(squirrel.GtOrEq{"date_created": *filter.DateFrom}) + } + + if filter.DateTo != nil { + endOfDay := filter.DateTo.Add(24*time.Hour - time.Nanosecond) + builder = builder.Where(squirrel.LtOrEq{"date_created": endOfDay}) + } + + // Mendapatkan SQL dan argumen + sql, args, err := builder.ToSql() + if err != nil { + return fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + if err := dbConn.QueryRowContext(ctx, sql, args...).Scan(total); err != nil { + return fmt.Errorf("total count query failed: %w", err) + } + + return nil +} + +// Enhanced fetchRetribusis dengan filter support menggunakan query builder +func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB, filter retribusi.RetribusiFilter, limit, offset int) ([]retribusi.Retribusi, error) { + // Build query untuk fetch data + builder := h.builder. + Select( + "id", "status", "sort", "user_created", "date_created", "user_updated", "date_updated", + "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", + "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", + "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + ). + From("data_retribusi"). + Where(squirrel.NotEq{"status": "deleted"}). + OrderBy("date_created DESC NULLS LAST"). + Limit(uint64(limit)). + Offset(uint64(offset)) + + // Menambahkan filter berdasarkan parameter + if filter.Status != nil { + builder = builder.Where(squirrel.Eq{"status": *filter.Status}) + } + + if filter.Jenis != nil { + builder = builder.Where(squirrel.Like{"Jenis": "%" + *filter.Jenis + "%"}) + } + + if filter.Dinas != nil { + builder = builder.Where(squirrel.Like{"Dinas": "%" + *filter.Dinas + "%"}) + } + + if filter.KelompokObyek != nil { + builder = builder.Where(squirrel.Like{"Kelompok_obyek": "%" + *filter.KelompokObyek + "%"}) + } + + if filter.Search != nil { + searchTerm := "%" + *filter.Search + "%" + builder = builder.Where(squirrel.Or{ + squirrel.Like{"Jenis": searchTerm}, + squirrel.Like{"Pelayanan": searchTerm}, + squirrel.Like{"Dinas": searchTerm}, + squirrel.Like{"Kode_tarif": searchTerm}, + squirrel.Like{"Uraian_1": searchTerm}, + squirrel.Like{"Uraian_2": searchTerm}, + squirrel.Like{"Uraian_3": searchTerm}, + }) + } + + if filter.DateFrom != nil { + builder = builder.Where(squirrel.GtOrEq{"date_created": *filter.DateFrom}) + } + + if filter.DateTo != nil { + endOfDay := filter.DateTo.Add(24*time.Hour - time.Nanosecond) + builder = builder.Where(squirrel.LtOrEq{"date_created": endOfDay}) + } + + // Mendapatkan SQL dan argumen + sql, args, err := builder.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + // Eksekusi query + rows, err := dbConn.QueryContext(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("fetch retribusis query failed: %w", err) + } + defer rows.Close() + + // Pre-allocate slice dengan kapasitas yang tepat + retribusis := make([]retribusi.Retribusi, 0, limit) + + for rows.Next() { + retribusi, err := h.scanRetribusi(rows) + if err != nil { + return nil, fmt.Errorf("scan retribusi failed: %w", err) + } + retribusis = append(retribusis, retribusi) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + logger.Info("Successfully fetched retribusis", map[string]interface{}{ + "count": len(retribusis), + "limit": limit, + "offset": offset, + }) + return retribusis, nil +} + +// Calculate pagination metadata +func (h *RetribusiHandler) calculateMeta(limit, offset, total int) models.MetaResponse { + totalPages := 0 + currentPage := 1 + + if limit > 0 { + totalPages = (total + limit - 1) / limit // Ceiling division + currentPage = (offset / limit) + 1 + } + + return models.MetaResponse{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + CurrentPage: currentPage, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} + +// validateRetribusiSubmission performs validation for duplicate entries and daily submission limits +func (h *RetribusiHandler) validateRetribusiSubmission(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) error { + // Import the validation utility + validator := validation.NewDuplicateValidator(dbConn) + + // Use default retribusi configuration + config := validation.DefaultRetribusiConfig() + + // Validate duplicate entries with active status for today + err := validator.ValidateDuplicate(ctx, config, "dummy_id") + if err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Validate once per day submission + err = validator.ValidateOncePerDay(ctx, "data_retribusi", "id", "date_created", "daily_limit") + if err != nil { + return fmt.Errorf("daily submission limit exceeded: %w", err) + } + + return nil +} + +// Example usage of the validation utility with custom configuration +func (h *RetribusiHandler) validateWithCustomConfig(ctx context.Context, dbConn *sql.DB, req *retribusi.RetribusiCreateRequest) error { + // Create validator instance + validator := validation.NewDuplicateValidator(dbConn) + + // Use custom configuration + config := validation.ValidationConfig{ + TableName: "data_retribusi", + IDColumn: "id", + StatusColumn: "status", + DateColumn: "date_created", + ActiveStatuses: []string{"active", "draft"}, + AdditionalFields: map[string]interface{}{ + "jenis": req.Jenis, + "dinas": req.Dinas, + }, + } + + // Validate with custom fields + fields := map[string]interface{}{ + "jenis": *req.Jenis, + "dinas": *req.Dinas, + } + + err := validator.ValidateDuplicateWithCustomFields(ctx, config, fields) + if err != nil { + return fmt.Errorf("custom validation failed: %w", err) + } + + return nil +} + +// GetLastSubmissionTime example +func (h *RetribusiHandler) getLastSubmissionTimeExample(ctx context.Context, dbConn *sql.DB, identifier string) (*time.Time, error) { + validator := validation.NewDuplicateValidator(dbConn) + return validator.GetLastSubmissionTime(ctx, "data_retribusi", "id", "date_created", identifier) +} diff --git a/internal/utils/filters/dynamic_filter.go b/internal/utils/filters/dynamic_filter.go index d6b86855..cd32e52a 100644 --- a/internal/utils/filters/dynamic_filter.go +++ b/internal/utils/filters/dynamic_filter.go @@ -61,12 +61,23 @@ type SortField struct { Order string `json:"order"` // ASC, DESC } +// UpdateData represents data for UPDATE operations +type UpdateData struct { + Columns []string `json:"columns"` + Values []interface{} `json:"values"` +} + +// InsertData represents data for INSERT operations +type InsertData struct { + Columns []string `json:"columns"` + Values []interface{} `json:"values"` +} + // QueryBuilder builds SQL queries from dynamic filters type QueryBuilder struct { tableName string columnMapping map[string]string // Maps API field names to DB column names allowedColumns map[string]bool // Security: only allow specified columns - // PERUBAHAN 1: Hapus paramCounter dan mu untuk membuat QueryBuilder stateless dan thread-safe. } // NewQueryBuilder creates a new query builder instance @@ -85,8 +96,6 @@ func (qb *QueryBuilder) SetColumnMapping(mapping map[string]string) *QueryBuilde } // SetAllowedColumns sets the list of allowed columns for security -// PERUBAHAN 3: Nama kolom di sini seharusnya adalah nama kolom ASLI di database -// untuk pemeriksaan keamanan yang lebih konsisten. func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder { qb.allowedColumns = make(map[string]bool) for _, col := range columns { @@ -95,10 +104,8 @@ func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder { return qb } -// BuildQuery builds the complete SQL query +// BuildQuery builds the complete SQL SELECT query func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) { - // PERUBAHAN 1: paramCounter sekarang lokal untuk fungsi ini. - // Ini membuat QueryBuilder aman untuk digunakan secara konkuren (thread-safe). paramCounter := 0 args := []interface{}{} @@ -164,6 +171,197 @@ func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, e return sql, args, nil } +// BuildInsertQuery builds an INSERT query +func (qb *QueryBuilder) BuildInsertQuery(data InsertData, returningColumns ...string) (string, []interface{}, error) { + if len(data.Columns) == 0 || len(data.Values) == 0 { + return "", nil, fmt.Errorf("no columns or values provided for INSERT") + } + + if len(data.Columns) != len(data.Values) { + return "", nil, fmt.Errorf("columns and values count mismatch for INSERT") + } + + paramCounter := 0 + args := []interface{}{} + + // Build column names + var columns []string + for _, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + columns = append(columns, fmt.Sprintf(`"%s"`, mappedCol)) + } + + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns provided for INSERT") + } + + // Build value placeholders + var placeholders []string + for i := 0; i < len(columns); i++ { + paramCounter++ + placeholders = append(placeholders, fmt.Sprintf("$%d", paramCounter)) + args = append(args, data.Values[i]) + } + + // Build INSERT clause + insertClause := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + qb.tableName, + strings.Join(columns, ", "), + strings.Join(placeholders, ", "), + ) + + // Build RETURNING clause if specified + returningClause := qb.buildReturningClause(returningColumns) + + // Combine all parts + sqlParts := []string{insertClause} + if returningClause != "" { + sqlParts = append(sqlParts, returningClause) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} + +// BuildUpdateQuery builds an UPDATE query +func (qb *QueryBuilder) BuildUpdateQuery(data UpdateData, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) { + if len(data.Columns) == 0 || len(data.Values) == 0 { + return "", nil, fmt.Errorf("no columns or values provided for UPDATE") + } + + if len(data.Columns) != len(data.Values) { + return "", nil, fmt.Errorf("columns and values count mismatch for UPDATE") + } + + paramCounter := 0 + args := []interface{}{} + + // Build SET clause + var setParts []string + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + paramCounter++ + setParts = append(setParts, fmt.Sprintf(`"%s" = $%d`, mappedCol, paramCounter)) + args = append(args, data.Values[i]) + } + + if len(setParts) == 0 { + return "", nil, fmt.Errorf("no valid columns provided for UPDATE") + } + + setClause := "SET " + strings.Join(setParts, ", ") + + // Build WHERE clause + whereClause, whereArgs, err := qb.buildWhereClause(filters, ¶mCounter) + if err != nil { + return "", nil, err + } + args = append(args, whereArgs...) + + // Build RETURNING clause if specified + returningClause := qb.buildReturningClause(returningColumns) + + // Combine all parts + sqlParts := []string{ + fmt.Sprintf("UPDATE %s", qb.tableName), + setClause, + } + + if whereClause != "" { + sqlParts = append(sqlParts, "WHERE "+whereClause) + } + + if returningClause != "" { + sqlParts = append(sqlParts, returningClause) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} + +// BuildDeleteQuery builds a DELETE query +func (qb *QueryBuilder) BuildDeleteQuery(filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) { + paramCounter := 0 + args := []interface{}{} + + // Build DELETE clause + deleteClause := fmt.Sprintf("DELETE FROM %s", qb.tableName) + + // Build WHERE clause + whereClause, whereArgs, err := qb.buildWhereClause(filters, ¶mCounter) + if err != nil { + return "", nil, err + } + args = append(args, whereArgs...) + + // Build RETURNING clause if specified + returningClause := qb.buildReturningClause(returningColumns) + + // Combine all parts + sqlParts := []string{deleteClause} + + if whereClause != "" { + sqlParts = append(sqlParts, "WHERE "+whereClause) + } + + if returningClause != "" { + sqlParts = append(sqlParts, returningClause) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} + +// BuildCountQuery builds a count query +func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) { + paramCounter := 0 + args := []interface{}{} + + // Build FROM clause + fromClause := fmt.Sprintf("FROM %s", qb.tableName) + + // Build WHERE clause + whereClause, whereArgs, err := qb.buildWhereClause(query.Filters, ¶mCounter) + if err != nil { + return "", nil, err + } + args = append(args, whereArgs...) + + // Build GROUP BY clause + groupClause := qb.buildGroupByClause(query.GroupBy) + + // Build HAVING clause + havingClause, havingArgs, err := qb.buildHavingClause(query.Having, ¶mCounter) + if err != nil { + return "", nil, err + } + args = append(args, havingArgs...) + + // Combine parts + sqlParts := []string{"SELECT COUNT(*)", fromClause} + + if whereClause != "" { + sqlParts = append(sqlParts, "WHERE "+whereClause) + } + + if groupClause != "" { + sqlParts = append(sqlParts, groupClause) + } + + if havingClause != "" { + sqlParts = append(sqlParts, "HAVING "+havingClause) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} + // buildSelectClause builds the SELECT part of the query func (qb *QueryBuilder) buildSelectClause(fields []string) string { if len(fields) == 0 || (len(fields) == 1 && fields[0] == "*") { @@ -182,15 +380,9 @@ func (qb *QueryBuilder) buildSelectClause(fields []string) string { continue } - // PERUBAHAN 3: Lakukan mapping terlebih dahulu, lalu pemeriksaan keamanan. - mappedCol := field - if mapped, exists := qb.columnMapping[field]; exists { - mappedCol = mapped - } - - // Security check: hanya izinkan kolom yang sudah ditentukan (cek nama kolom DB) - if len(qb.allowedColumns) > 0 && !qb.allowedColumns[mappedCol] { - continue // Lewati kolom yang tidak diizinkan + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol == "" { + continue // Skip invalid columns } selectedFields = append(selectedFields, fmt.Sprintf(`"%s"`, mappedCol)) @@ -203,6 +395,55 @@ func (qb *QueryBuilder) buildSelectClause(fields []string) string { return "SELECT " + strings.Join(selectedFields, ", ") } +// buildReturningClause builds the RETURNING part of the query +func (qb *QueryBuilder) buildReturningClause(columns []string) string { + if len(columns) == 0 { + return "" + } + + var returningFields []string + for _, field := range columns { + if field == "*" { + returningFields = append(returningFields, "*") + continue + } + + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol == "" { + continue // Skip invalid columns + } + + returningFields = append(returningFields, fmt.Sprintf(`"%s"`, mappedCol)) + } + + if len(returningFields) == 0 { + return "" + } + + return "RETURNING " + strings.Join(returningFields, ", ") +} + +// mapAndValidateColumn maps a column name and validates it +func (qb *QueryBuilder) mapAndValidateColumn(field string) string { + // Map the column name + mappedCol := field + if mapped, exists := qb.columnMapping[field]; exists { + mappedCol = mapped + } + + // Security check: only allow specified columns + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[mappedCol] { + return "" // Skip invalid columns + } + + // Additional security: Validate column name format + if !qb.isValidColumnName(mappedCol) { + return "" // Skip invalid columns + } + + return mappedCol +} + // buildWhereClause builds the WHERE part of the query func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup, paramCounter *int) (string, []interface{}, error) { if len(filterGroups) == 0 { @@ -213,7 +454,6 @@ func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup, paramCounte var allArgs []interface{} for i, group := range filterGroups { - // PERUBAHAN 2: Tambahkan tanda kurung untuk setiap grup untuk memastikan urutan operasi yang benar. groupCondition, groupArgs, err := qb.buildFilterGroup(group, paramCounter) if err != nil { return "", nil, err @@ -271,20 +511,10 @@ func (qb *QueryBuilder) buildFilterGroup(group FilterGroup, paramCounter *int) ( // buildFilterCondition builds a single filter condition func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter, paramCounter *int) (string, []interface{}, error) { - // PERUBAHAN 3: Lakukan mapping terlebih dahulu, lalu pemeriksaan keamanan. - column := filter.Column - if mappedCol, exists := qb.columnMapping[column]; exists { - column = mappedCol - } - - // Security check (cek nama kolom DB hasil mapping) - if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { - return "", nil, nil - } - - // Additional security: Validate column name format - if !qb.isValidColumnName(column) { - return "", nil, fmt.Errorf("invalid column name: %s", column) + // Map and validate the column name + column := qb.mapAndValidateColumn(filter.Column) + if column == "" { + return "", nil, nil // Skip invalid columns } // Wrap column name in quotes for PostgreSQL @@ -292,7 +522,6 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter, paramCounter switch filter.Operator { case OpEqual: - // PERUBAHAN 4: Tangani nilai nil secara eksplisit untuk operator kesetaraan. if filter.Value == nil { return fmt.Sprintf("%s IS NULL", column), nil, nil } @@ -300,7 +529,6 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter, paramCounter return fmt.Sprintf("%s = $%d", column, *paramCounter), []interface{}{filter.Value}, nil case OpNotEqual: - // PERUBAHAN 4: Tangani nilai nil secara eksplisit untuk operator ketidaksamaan. if filter.Value == nil { return fmt.Sprintf("%s IS NOT NULL", column), nil, nil } @@ -492,15 +720,9 @@ func (qb *QueryBuilder) buildOrderClause(sortFields []SortField) string { var orderParts []string for _, sort := range sortFields { - // PERUBAHAN 3: Lakukan mapping dan pemeriksaan keamanan. - column := sort.Column - if mappedCol, exists := qb.columnMapping[column]; exists { - column = mappedCol - } - - // Security check (cek nama kolom DB hasil mapping) - if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { - continue + column := qb.mapAndValidateColumn(sort.Column) + if column == "" { + continue // Skip invalid columns } order := "ASC" @@ -526,15 +748,9 @@ func (qb *QueryBuilder) buildGroupByClause(groupFields []string) string { var groupParts []string for _, field := range groupFields { - // PERUBAHAN 3: Lakukan mapping dan pemeriksaan keamanan. - column := field - if mappedCol, exists := qb.columnMapping[column]; exists { - column = mappedCol - } - - // Security check (cek nama kolom DB hasil mapping) - if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { - continue + column := qb.mapAndValidateColumn(field) + if column == "" { + continue // Skip invalid columns } groupParts = append(groupParts, fmt.Sprintf(`"%s"`, column)) @@ -556,51 +772,6 @@ func (qb *QueryBuilder) buildHavingClause(havingGroups []FilterGroup, paramCount return qb.buildWhereClause(havingGroups, paramCounter) } -// BuildCountQuery builds a count query -func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) { - // PERUBAHAN 1: paramCounter lokal. - paramCounter := 0 - args := []interface{}{} - - // Build FROM clause - fromClause := fmt.Sprintf("FROM %s", qb.tableName) - - // Build WHERE clause - whereClause, whereArgs, err := qb.buildWhereClause(query.Filters, ¶mCounter) - if err != nil { - return "", nil, err - } - args = append(args, whereArgs...) - - // Build GROUP BY clause - groupClause := qb.buildGroupByClause(query.GroupBy) - - // Build HAVING clause - havingClause, havingArgs, err := qb.buildHavingClause(query.Having, ¶mCounter) - if err != nil { - return "", nil, err - } - args = append(args, havingArgs...) - - // Combine parts - sqlParts := []string{"SELECT COUNT(*)", fromClause} - - if whereClause != "" { - sqlParts = append(sqlParts, "WHERE "+whereClause) - } - - if groupClause != "" { - sqlParts = append(sqlParts, groupClause) - } - - if havingClause != "" { - sqlParts = append(sqlParts, "HAVING "+havingClause) - } - - sql := strings.Join(sqlParts, " ") - return sql, args, nil -} - // isValidColumnName validates column name format to prevent SQL injection func (qb *QueryBuilder) isValidColumnName(column string) bool { if column == "" { @@ -608,7 +779,6 @@ func (qb *QueryBuilder) isValidColumnName(column string) bool { } // Allow only alphanumeric characters, underscores, and dots (for table.column format) - // This is more restrictive than before for better security for _, r := range column { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '.') { @@ -632,3 +802,96 @@ func (qb *QueryBuilder) isValidColumnName(column string) bool { return true } + +// BuildUpsertQuery builds an UPSERT (INSERT ... ON CONFLICT UPDATE) query for PostgreSQL +func (qb *QueryBuilder) BuildUpsertQuery(data InsertData, conflictColumns []string, updateData UpdateData, returningColumns ...string) (string, []interface{}, error) { + if len(data.Columns) == 0 || len(data.Values) == 0 { + return "", nil, fmt.Errorf("no columns or values provided for UPSERT") + } + + if len(data.Columns) != len(data.Values) { + return "", nil, fmt.Errorf("columns and values count mismatch for UPSERT") + } + + if len(conflictColumns) == 0 { + return "", nil, fmt.Errorf("no conflict columns provided for UPSERT") + } + + paramCounter := 0 + args := []interface{}{} + + // Build column names + var columns []string + for _, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + columns = append(columns, fmt.Sprintf(`"%s"`, mappedCol)) + } + + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns provided for UPSERT") + } + + // Build value placeholders + var placeholders []string + for i := 0; i < len(columns); i++ { + paramCounter++ + placeholders = append(placeholders, fmt.Sprintf("$%d", paramCounter)) + args = append(args, data.Values[i]) + } + + // Build INSERT clause + insertClause := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + qb.tableName, + strings.Join(columns, ", "), + strings.Join(placeholders, ", "), + ) + + // Build ON CONFLICT clause + var conflictCols []string + for _, col := range conflictColumns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + conflictCols = append(conflictCols, fmt.Sprintf(`"%s"`, mappedCol)) + } + + if len(conflictCols) == 0 { + return "", nil, fmt.Errorf("no valid conflict columns provided for UPSERT") + } + + conflictClause := fmt.Sprintf("ON CONFLICT (%s)", strings.Join(conflictCols, ", ")) + + // Build UPDATE clause + var updateParts []string + for i, col := range updateData.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + paramCounter++ + updateParts = append(updateParts, fmt.Sprintf(`"%s" = $%d`, mappedCol, paramCounter)) + args = append(args, updateData.Values[i]) + } + + if len(updateParts) == 0 { + return "", nil, fmt.Errorf("no valid update columns provided for UPSERT") + } + + updateClause := "DO UPDATE SET " + strings.Join(updateParts, ", ") + + // Build RETURNING clause if specified + returningClause := qb.buildReturningClause(returningColumns) + + // Combine all parts + sqlParts := []string{insertClause, conflictClause, updateClause} + if returningClause != "" { + sqlParts = append(sqlParts, returningClause) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} diff --git a/internal/utils/query/builder.go b/internal/utils/query/builder.go new file mode 100644 index 00000000..a119dd1a --- /dev/null +++ b/internal/utils/query/builder.go @@ -0,0 +1,1139 @@ +package utils + +import ( + "fmt" + "net/url" + "reflect" + "strconv" + "strings" + "time" + + "github.com/Masterminds/squirrel" + // Still useful for array types, especially with PostgreSQL +) + +// DBType represents the type of database +type DBType string + +const ( + DBTypePostgreSQL DBType = "postgres" + DBTypeMySQL DBType = "mysql" + DBTypeSQLite DBType = "sqlite" + DBTypeSQLServer DBType = "sqlserver" +) + +// FilterOperator represents supported filter operators +type FilterOperator string + +const ( + OpEqual FilterOperator = "_eq" + OpNotEqual FilterOperator = "_neq" + OpLike FilterOperator = "_like" + OpILike FilterOperator = "_ilike" + 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" +) + +// DynamicFilter represents a single filter condition +type DynamicFilter struct { + Column string `json:"column"` + Operator FilterOperator `json:"operator"` + Value interface{} `json:"value"` + LogicOp string `json:"logic_op,omitempty"` // AND, OR +} + +// FilterGroup represents a group of filters +type FilterGroup struct { + Filters []DynamicFilter `json:"filters"` + LogicOp string `json:"logic_op"` // AND, OR +} + +// DynamicQuery represents the complete query structure +type DynamicQuery struct { + Fields []string `json:"fields,omitempty"` + Filters []FilterGroup `json:"filters,omitempty"` + Sort []SortField `json:"sort,omitempty"` + Limit int `json:"limit"` + Offset int `json:"offset"` + GroupBy []string `json:"group_by,omitempty"` + Having []FilterGroup `json:"having,omitempty"` +} + +// 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"` +} + +// InsertData represents data for INSERT operations +type InsertData struct { + Columns []string `json:"columns"` + Values []interface{} `json:"values"` +} + +// QueryBuilder builds SQL queries from dynamic filters using squirrel +type QueryBuilder struct { + tableName string + columnMapping map[string]string // Maps API field names to DB column names + allowedColumns map[string]bool // Security: only allow specified columns + dbType DBType + sqlBuilder squirrel.StatementBuilderType +} + +// NewQueryBuilder creates a new query builder instance for a specific database type +func NewQueryBuilder(tableName string, dbType DBType) *QueryBuilder { + var placeholderFormat squirrel.PlaceholderFormat + + switch dbType { + case DBTypePostgreSQL: + placeholderFormat = squirrel.Dollar + case DBTypeMySQL, DBTypeSQLite: + placeholderFormat = squirrel.Question + case DBTypeSQLServer: + placeholderFormat = squirrel.AtP + default: + // Default to a common format if an unknown type is provided + placeholderFormat = squirrel.Question + } + + return &QueryBuilder{ + tableName: tableName, + columnMapping: make(map[string]string), + allowedColumns: make(map[string]bool), + dbType: dbType, + sqlBuilder: squirrel.StatementBuilder.PlaceholderFormat(placeholderFormat), + } +} + +// SetColumnMapping sets the mapping between API field names and database column names +func (qb *QueryBuilder) SetColumnMapping(mapping map[string]string) *QueryBuilder { + qb.columnMapping = mapping + return qb +} + +// SetAllowedColumns sets the list of allowed columns for security +func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder { + qb.allowedColumns = make(map[string]bool) + for _, col := range columns { + qb.allowedColumns[col] = true + } + return qb +} + +// BuildQuery builds the complete SQL SELECT query +func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) { + // Start with base query + baseQuery := qb.sqlBuilder.Select(qb.buildSelectFields(query.Fields)...).From(qb.tableName) + + // Apply WHERE conditions + if len(query.Filters) > 0 { + whereClause, args, err := qb.buildWhereClause(query.Filters) + if err != nil { + return "", nil, err + } + baseQuery = baseQuery.Where(whereClause, args...) + } + + // Apply GROUP BY + if len(query.GroupBy) > 0 { + groupByCols := qb.buildGroupByColumns(query.GroupBy) + if len(groupByCols) > 0 { + baseQuery = baseQuery.GroupBy(groupByCols...) + } + } + + // Apply HAVING conditions + if len(query.Having) > 0 { + havingClause, args, err := qb.buildWhereClause(query.Having) + if err != nil { + return "", nil, err + } + baseQuery = baseQuery.Having(havingClause, args...) + } + + // Apply ORDER BY + if len(query.Sort) > 0 { + for _, sort := range query.Sort { + column := qb.mapAndValidateColumn(sort.Column) + if column == "" { + continue // Skip invalid columns + } + order := "ASC" + if strings.ToUpper(sort.Order) == "DESC" { + order = "DESC" + } + baseQuery = baseQuery.OrderBy(fmt.Sprintf("%s %s", squirrel.EscapeIdentifier(column), order)) + } + } + + // Apply pagination with dialect-specific syntax + if query.Limit > 0 { + if qb.dbType == DBTypeSQLServer { + // SQL Server uses OFFSET-FETCH syntax + baseQuery = baseQuery.Offset(uint64(query.Offset)).Fetch(uint64(query.Limit)) + } else { + // PostgreSQL, MySQL, SQLite use LIMIT/OFFSET + baseQuery = baseQuery.Limit(uint64(query.Limit)) + if query.Offset > 0 { + baseQuery = baseQuery.Offset(uint64(query.Offset)) + } + } + } else if query.Offset > 0 && qb.dbType != DBTypeSQLServer { + // SQL Server requires FETCH with OFFSET + baseQuery = baseQuery.Offset(uint64(query.Offset)) + } + + // Build final query + sql, args, err := baseQuery.ToSql() + if err != nil { + return "", nil, fmt.Errorf("failed to build query: %w", err) + } + + return sql, args, nil +} + +// BuildInsertQuery builds an INSERT query +func (qb *QueryBuilder) BuildInsertQuery(data InsertData, returningColumns ...string) (string, []interface{}, error) { + if len(data.Columns) == 0 || len(data.Values) == 0 { + return "", nil, fmt.Errorf("no columns or values provided for INSERT") + } + + if len(data.Columns) != len(data.Values) { + return "", nil, fmt.Errorf("columns and values count mismatch for INSERT") + } + + // Prepare columns and values + columns := make([]string, 0, len(data.Columns)) + values := make([]interface{}, 0, len(data.Values)) + + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + columns = append(columns, mappedCol) + values = append(values, data.Values[i]) + } + + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns provided for INSERT") + } + + // Build INSERT query + query := qb.sqlBuilder.Insert(qb.tableName).Columns(columns...).Values(values...) + + // Add RETURNING/OUTPUT clause if specified and supported + if len(returningColumns) > 0 { + returningClause := qb.buildReturningClause(returningColumns) + if returningClause != "" { + query = query.Suffix(returningClause) + } + } + + sql, args, err := query.ToSql() + if err != nil { + return "", nil, fmt.Errorf("failed to build INSERT query: %w", err) + } + + return sql, args, nil +} + +// BuildUpdateQuery builds an UPDATE query +func (qb *QueryBuilder) BuildUpdateQuery(data UpdateData, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) { + if len(data.Columns) == 0 || len(data.Values) == 0 { + return "", nil, fmt.Errorf("no columns or values provided for UPDATE") + } + + if len(data.Columns) != len(data.Values) { + return "", nil, fmt.Errorf("columns and values count mismatch for UPDATE") + } + + // Prepare SET clause + setMap := make(map[string]interface{}) + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue // Skip invalid columns + } + setMap[mappedCol] = data.Values[i] + } + + if len(setMap) == 0 { + return "", nil, fmt.Errorf("no valid columns provided for UPDATE") + } + + // Build UPDATE query + query := qb.sqlBuilder.Update(qb.tableName).SetMap(setMap) + + // Apply WHERE conditions + if len(filters) > 0 { + whereClause, args, err := qb.buildWhereClause(filters) + if err != nil { + return "", nil, err + } + query = query.Where(whereClause, args...) + } + + // Add RETURNING/OUTPUT clause if specified and supported + if len(returningColumns) > 0 { + returningClause := qb.buildReturningClause(returningColumns) + if returningClause != "" { + query = query.Suffix(returningClause) + } + } + + sql, args, err := query.ToSql() + if err != nil { + return "", nil, fmt.Errorf("failed to build UPDATE query: %w", err) + } + + return sql, args, nil +} + +// BuildDeleteQuery builds a DELETE query +func (qb *QueryBuilder) BuildDeleteQuery(filters []FilterGroup, returningColumns ...string) (string, []interface{}, error) { + // Build DELETE query + query := qb.sqlBuilder.Delete(qb.tableName) + + // Apply WHERE conditions + if len(filters) > 0 { + whereClause, args, err := qb.buildWhereClause(filters) + if err != nil { + return "", nil, err + } + query = query.Where(whereClause, args...) + } + + // Add RETURNING/OUTPUT clause if specified and supported + if len(returningColumns) > 0 { + returningClause := qb.buildReturningClause(returningColumns) + if returningClause != "" { + query = query.Suffix(returningClause) + } + } + + sql, args, err := query.ToSql() + if err != nil { + return "", nil, fmt.Errorf("failed to build DELETE query: %w", err) + } + + return sql, args, nil +} + +// BuildCountQuery builds a count query +func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) { + // Start with COUNT query + baseQuery := qb.sqlBuilder.Select("COUNT(*)").From(qb.tableName) + + // Apply WHERE conditions + if len(query.Filters) > 0 { + whereClause, args, err := qb.buildWhereClause(query.Filters) + if err != nil { + return "", nil, err + } + baseQuery = baseQuery.Where(whereClause, args...) + } + + // Apply GROUP BY + if len(query.GroupBy) > 0 { + groupByCols := qb.buildGroupByColumns(query.GroupBy) + if len(groupByCols) > 0 { + baseQuery = baseQuery.GroupBy(groupByCols...) + } + } + + // Apply HAVING conditions + if len(query.Having) > 0 { + havingClause, args, err := qb.buildWhereClause(query.Having) + if err != nil { + return "", nil, err + } + baseQuery = baseQuery.Having(havingClause, args...) + } + + sql, args, err := baseQuery.ToSql() + if err != nil { + return "", nil, fmt.Errorf("failed to build COUNT query: %w", err) + } + + return sql, args, nil +} + +// BuildUpsertQuery builds an upsert query with dialect-specific syntax +func (qb *QueryBuilder) BuildUpsertQuery(data InsertData, conflictColumns []string, updateData UpdateData, returningColumns ...string) (string, []interface{}, error) { + switch qb.dbType { + case DBTypePostgreSQL, DBTypeSQLite: + return qb.buildPostgresUpsert(data, conflictColumns, updateData, returningColumns...) + case DBTypeMySQL: + return qb.buildMySQLUpsert(data, conflictColumns, updateData, returningColumns...) + case DBTypeSQLServer: + return qb.buildSQLServerUpsert(data, conflictColumns, updateData, returningColumns...) + default: + return "", nil, fmt.Errorf("upsert operation not supported for database type: %s", qb.dbType) + } +} + +// buildPostgresUpsert builds an UPSERT query for PostgreSQL/SQLite (ON CONFLICT) +func (qb *QueryBuilder) buildPostgresUpsert(data InsertData, conflictColumns []string, updateData UpdateData, returningColumns ...string) (string, []interface{}, error) { + // ... (Validation logic from the original BuildUpsertQuery) ... + if len(data.Columns) == 0 || len(data.Values) == 0 || len(data.Columns) != len(data.Values) || len(conflictColumns) == 0 { + return "", nil, fmt.Errorf("invalid arguments for PostgreSQL/SQLite upsert") + } + + // Prepare columns and values + columns := make([]string, 0, len(data.Columns)) + values := make([]interface{}, 0, len(data.Values)) + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + columns = append(columns, mappedCol) + values = append(values, data.Values[i]) + } + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns for upsert") + } + + // Prepare conflict columns + conflictCols := make([]string, 0, len(conflictColumns)) + for _, col := range conflictColumns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + conflictCols = append(conflictCols, mappedCol) + } + if len(conflictCols) == 0 { + return "", nil, fmt.Errorf("no valid conflict columns for upsert") + } + + // Prepare update clause + updateMap := make(map[string]interface{}) + for i, col := range updateData.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + updateMap[mappedCol] = updateData.Values[i] + } + if len(updateMap) == 0 { + return "", nil, fmt.Errorf("no valid update columns for upsert") + } + + // Build query + query := qb.sqlBuilder.Insert(qb.tableName).Columns(columns...).Values(values...) + + // ON CONFLICT clause + onConflictSuffix := fmt.Sprintf("ON CONFLICT (%s) DO UPDATE SET %s", + strings.Join(conflictCols, ", "), + qb.buildUpdateSetClause(updateMap)) + query = query.Suffix(onConflictSuffix) + + // Add RETURNING clause if specified + if len(returningColumns) > 0 { + returningClause := qb.buildReturningClause(returningColumns) + if returningClause != "" { + query = query.Suffix(" " + returningClause) // Prepend space + } + } + + sql, args, err := query.ToSql() + return sql, args, err +} + +// buildMySQLUpsert builds an UPSERT query for MySQL (ON DUPLICATE KEY UPDATE) +func (qb *QueryBuilder) buildMySQLUpsert(data InsertData, conflictColumns []string, updateData UpdateData, returningColumns ...string) (string, []interface{}, error) { + // ... (Validation logic) ... + if len(data.Columns) == 0 || len(data.Values) == 0 || len(data.Columns) != len(data.Values) || len(updateData.Columns) == 0 { + return "", nil, fmt.Errorf("invalid arguments for MySQL upsert") + } + + // Prepare columns and values + columns := make([]string, 0, len(data.Columns)) + values := make([]interface{}, 0, len(data.Values)) + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + columns = append(columns, mappedCol) + values = append(values, data.Values[i]) + } + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns for upsert") + } + + // Prepare update clause + var updateParts []string + updateArgs := make([]interface{}, 0) + for i, col := range updateData.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + // In MySQL, you can reference the new values with VALUES(column_name) + updateParts = append(updateParts, fmt.Sprintf("%s = VALUES(?)", squirrel.EscapeIdentifier(mappedCol))) + updateArgs = append(updateArgs, mappedCol) // The placeholder is for the column name itself + updateArgs = append(updateArgs, updateData.Values[i]) + } + if len(updateParts) == 0 { + return "", nil, fmt.Errorf("no valid update columns for upsert") + } + + // Build query + query := qb.sqlBuilder.Insert(qb.tableName).Columns(columns...).Values(values...) + + // ON DUPLICATE KEY UPDATE clause + onDuplicateSuffix := fmt.Sprintf("ON DUPLICATE KEY UPDATE %s", strings.Join(updateParts, ", ")) + query = query.Suffix(onDuplicateSuffix) + + // MySQL doesn't support RETURNING in the same way. This is a limitation. + // Applications would need to run a separate SELECT query. + // We will ignore returningColumns for MySQL. + + sql, args, err := query.ToSql() + if err != nil { + return "", nil, err + } + + // We need to merge args from the main query and the suffix + allArgs := append(args, updateArgs...) + + return sql, allArgs, nil +} + +// buildSQLServerUpsert builds an UPSERT query for SQL Server (MERGE) +func (qb *QueryBuilder) buildSQLServerUpsert(data InsertData, conflictColumns []string, updateData UpdateData, returningColumns ...string) (string, []interface{}, error) { + // MERGE is complex to build with squirrel's high-level API. + // We'll build it more manually but still use squirrel for escaping. + // This is a simplified version. A full MERGE can be more complex. + + // ... (Validation logic) ... + if len(data.Columns) == 0 || len(data.Values) == 0 || len(data.Columns) != len(data.Values) || len(conflictColumns) == 0 || len(updateData.Columns) == 0 { + return "", nil, fmt.Errorf("invalid arguments for SQL Server upsert") + } + + // Prepare columns and values + columns := make([]string, 0, len(data.Columns)) + values := make([]interface{}, 0, len(data.Values)) + for i, col := range data.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + columns = append(columns, mappedCol) + values = append(values, data.Values[i]) + } + if len(columns) == 0 { + return "", nil, fmt.Errorf("no valid columns for upsert") + } + + // Prepare conflict columns (target for ON clause) + var onConditions []string + for _, col := range conflictColumns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + onConditions = append(onConditions, fmt.Sprintf("target.%s = source.%s", squirrel.EscapeIdentifier(mappedCol), squirrel.EscapeIdentifier(mappedCol))) + } + + // Prepare update clause + var updateParts []string + for i, col := range updateData.Columns { + mappedCol := qb.mapAndValidateColumn(col) + if mappedCol == "" { + continue + } + updateParts = append(updateParts, fmt.Sprintf("target.%s = ?", squirrel.EscapeIdentifier(mappedCol))) + values = append(values, updateData.Values[i]) + } + + // Build MERGE statement manually + sql := fmt.Sprintf("MERGE INTO %s AS target USING (VALUES (%s)) AS source (%s) ON %s", + qb.tableName, + strings.Join(squirrel.Placeholders(len(columns)), ", "), + strings.Join(columns, ", "), + strings.Join(onConditions, " AND "), + ) + + sql += " WHEN MATCHED THEN UPDATE SET " + strings.Join(updateParts, ", ") + sql += " WHEN NOT MATCHED THEN INSERT (" + strings.Join(columns, ", ") + ") VALUES (" + strings.Join(squirrel.Placeholders(len(columns)), ", ") + ")" + + // Add OUTPUT clause if specified + if len(returningColumns) > 0 { + var outputFields []string + for _, field := range returningColumns { + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol != "" { + outputFields = append(outputFields, "INSERTED."+squirrel.EscapeIdentifier(mappedCol)) + } + } + if len(outputFields) > 0 { + sql += " OUTPUT " + strings.Join(outputFields, ", ") + } + } + + // Add final values for the INSERT part of MERGE + values = append(values, values...) + + return sql, values, nil +} + +// Helper methods + +// buildSelectFields builds the SELECT fields +func (qb *QueryBuilder) buildSelectFields(fields []string) []string { + if len(fields) == 0 || (len(fields) == 1 && fields[0] == "*") { + return []string{"*"} + } + + var selectedFields []string + for _, field := range fields { + if field == "*" { + selectedFields = append(selectedFields, "*") + continue + } + + if strings.Contains(field, "(") || strings.Contains(field, " ") { + if qb.isValidExpression(field) { + selectedFields = append(selectedFields, field) + } + continue + } + + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol != "" { + selectedFields = append(selectedFields, squirrel.EscapeIdentifier(mappedCol)) + } + } + + if len(selectedFields) == 0 { + return []string{"*"} + } + + return selectedFields +} + +// buildReturningClause builds the RETURNING (Postgres/SQLite) or OUTPUT (SQL Server) clause +func (qb *QueryBuilder) buildReturningClause(columns []string) string { + if len(columns) == 0 { + return "" + } + + var returningFields []string + for _, field := range columns { + if field == "*" { + returningFields = append(returningFields, "*") + continue + } + + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol != "" { + switch qb.dbType { + case DBTypeSQLServer: + returningFields = append(returningFields, "INSERTED."+squirrel.EscapeIdentifier(mappedCol)) + default: // Postgres, SQLite + returningFields = append(returningFields, squirrel.EscapeIdentifier(mappedCol)) + } + } + } + + if len(returningFields) == 0 { + return "" + } + + switch qb.dbType { + case DBTypeSQLServer: + return "OUTPUT " + strings.Join(returningFields, ", ") + default: // Postgres, SQLite + return "RETURNING " + strings.Join(returningFields, ", ") + } +} + +// buildGroupByColumns builds GROUP BY columns +func (qb *QueryBuilder) buildGroupByColumns(fields []string) []string { + var groupCols []string + for _, field := range fields { + mappedCol := qb.mapAndValidateColumn(field) + if mappedCol != "" { + groupCols = append(groupCols, squirrel.EscapeIdentifier(mappedCol)) + } + } + return groupCols +} + +// buildUpdateSetClause builds the SET clause for UPDATE (used by Postgres/SQLite upsert) +func (qb *QueryBuilder) buildUpdateSetClause(updateMap map[string]interface{}) string { + var setParts []string + for col := range updateMap { + setParts = append(setParts, fmt.Sprintf("%s = EXCLUDED.%s", squirrel.EscapeIdentifier(col), squirrel.EscapeIdentifier(col))) + } + return strings.Join(setParts, ", ") +} + +// buildWhereClause builds WHERE/HAVING conditions +func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error) { + if len(filterGroups) == 0 { + return "", nil, nil + } + + var conditions []string + var allArgs []interface{} + + for i, group := range filterGroups { + groupCondition, groupArgs, err := qb.buildFilterGroup(group) + if err != nil { + return "", nil, err + } + + if groupCondition != "" { + if i > 0 { + logicOp := "AND" + if group.LogicOp != "" { + logicOp = strings.ToUpper(group.LogicOp) + } + conditions = append(conditions, logicOp) + } + conditions = append(conditions, fmt.Sprintf("(%s)", groupCondition)) + allArgs = append(allArgs, groupArgs...) + } + } + + return strings.Join(conditions, " "), allArgs, nil +} + +// buildFilterGroup builds conditions for a filter group +func (qb *QueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface{}, error) { + if len(group.Filters) == 0 { + return "", nil, nil + } + + var conditions []string + var args []interface{} + + for i, filter := range group.Filters { + condition, filterArgs, err := qb.buildFilterCondition(filter) + if err != nil { + return "", nil, err + } + + if condition != "" { + if i > 0 { + logicOp := "AND" + if filter.LogicOp != "" { + logicOp = strings.ToUpper(filter.LogicOp) + } else if group.LogicOp != "" { + logicOp = strings.ToUpper(group.LogicOp) + } + conditions = append(conditions, logicOp) + } + + conditions = append(conditions, condition) + args = append(args, filterArgs...) + } + } + + return strings.Join(conditions, " "), args, nil +} + +// buildFilterCondition builds a single filter condition with dialect-specific logic +func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) { + column := qb.mapAndValidateColumn(filter.Column) + if column == "" { + return "", nil, nil + } + column = squirrel.EscapeIdentifier(column) + + 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 + } + // Dialect-specific case-insensitive LIKE + switch qb.dbType { + case DBTypePostgreSQL, DBTypeSQLite: + return fmt.Sprintf("%s ILIKE ?", column), []interface{}{filter.Value}, nil + case DBTypeMySQL, DBTypeSQLServer: + // Use LOWER() function for case-insensitive comparison + return fmt.Sprintf("LOWER(%s) LIKE LOWER(?)", column), []interface{}{filter.Value}, nil + default: + // Fallback to case-sensitive LIKE + return fmt.Sprintf("%s LIKE ?", column), []interface{}{filter.Value}, nil + } + case OpIn, OpNotIn: + values := qb.parseArrayValue(filter.Value) + if len(values) == 0 { + return "", nil, nil + } + op := "IN" + if filter.Operator == OpNotIn { + op = "NOT IN" + } + return fmt.Sprintf("%s %s (%s)", column, op, squirrel.Placeholders(len(values))), values, nil + case OpGreaterThan, OpGreaterThanEqual, OpLessThan, OpLessThanEqual: + if filter.Value == nil { + return "", nil, nil + } + op := strings.TrimPrefix(string(filter.Operator), "_") + return fmt.Sprintf("%s %s ?", column, op), []interface{}{filter.Value}, nil + case OpBetween, OpNotBetween: + if filter.Value == nil { + return "", nil, nil + } + 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: + value = fmt.Sprintf("%%%v%%", filter.Value) + case OpNotContains: + value = fmt.Sprintf("%%%v%%", filter.Value) + case OpStartsWith: + value = fmt.Sprintf("%v%%", filter.Value) + case OpEndsWith: + value = fmt.Sprintf("%%%v", filter.Value) + } + + // Use the same logic as ILike + switch qb.dbType { + case DBTypePostgreSQL, DBTypeSQLite: + op := "ILIKE" + if filter.Operator == OpNotContains { + op = "NOT ILIKE" + } + return fmt.Sprintf("%s %s ?", column, op), []interface{}{value}, nil + case DBTypeMySQL, DBTypeSQLServer: + op := "LIKE" + if filter.Operator == OpNotContains { + op = "NOT LIKE" + } + return fmt.Sprintf("LOWER(%s) %s LOWER(?)", column, op), []interface{}{value}, nil + default: + op := "LIKE" + if filter.Operator == OpNotContains { + 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 array values from various formats +func (qb *QueryBuilder) 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} +} + +// mapAndValidateColumn maps a column name and validates it +func (qb *QueryBuilder) mapAndValidateColumn(field string) string { + mappedCol := field + if mapped, exists := qb.columnMapping[field]; exists { + mappedCol = mapped + } + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[mappedCol] { + return "" + } + if !qb.isValidColumnName(mappedCol) { + return "" + } + return mappedCol +} + +// isValidColumnName validates column name format to prevent SQL injection +func (qb *QueryBuilder) isValidColumnName(column string) bool { + if column == "" { + return false + } + for _, r := range column { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '.') { + return false + } + } + suspiciousPatterns := []string{" ", ";", "--", "/*", "*/", "union", "select", "insert", "update", "delete", "drop", "alter", "create", "exec", "execute", "xp_", "sp_", "information_schema", "sysobjects", "syscolumns", "sysdatabases", "mysql", "pg_", "sqlite"} + lowerColumn := strings.ToLower(column) + for _, pattern := range suspiciousPatterns { + if strings.Contains(lowerColumn, pattern) { + return false + } + } + return true +} + +// isValidExpression validates SQL expressions +func (qb *QueryBuilder) isValidExpression(expr string) bool { + allowedChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.,() *-+/" + for _, r := range expr { + if !strings.ContainsRune(allowedChars, r) { + return false + } + } + dangerousPatterns := []string{";", "--", "/*", "*/", "union", "select", "insert", "update", "delete", "drop", "alter", "create", "exec", "execute"} + lowerExpr := strings.ToLower(expr) + for _, pattern := range dangerousPatterns { + if strings.Contains(lowerExpr, pattern) { + return false + } + } + return true +} + +// ValidateInput performs comprehensive input validation +func (qb *QueryBuilder) ValidateInput(input interface{}) error { + switch v := input.(type) { + case string: + if len(v) > 1000 { + return fmt.Errorf("input string too long") + } + if strings.Contains(v, "\x00") { + return fmt.Errorf("invalid characters in input") + } + case []string: + if len(v) > 100 { + return fmt.Errorf("too many items in array") + } + for _, item := range v { + if err := qb.ValidateInput(item); err != nil { + return err + } + } + } + return nil +} + +// SanitizeString sanitizes string inputs +func (qb *QueryBuilder) SanitizeString(input string) string { + return strings.TrimSpace(strings.ReplaceAll(input, "\x00", "")) +} + +// QueryParser parses HTTP query parameters into DynamicQuery +type QueryParser struct { + defaultLimit int + maxLimit int +} + +// NewQueryParser creates a new query parser +func NewQueryParser() *QueryParser { + return &QueryParser{defaultLimit: 10, maxLimit: 100} +} + +// SetLimits sets default and maximum limits +func (qp *QueryParser) SetLimits(defaultLimit, maxLimit int) *QueryParser { + qp.defaultLimit = defaultLimit + qp.maxLimit = maxLimit + return qp +} + +// ParseQuery parses URL query parameters into DynamicQuery +func (qp *QueryParser) ParseQuery(values url.Values) (DynamicQuery, error) { + query := DynamicQuery{Limit: qp.defaultLimit, Offset: 0} + // ... (implementation remains the same as before) ... + if fields := values.Get("fields"); fields != "" { + if fields == "*.*" || fields == "*" { + query.Fields = []string{"*"} + } else { + query.Fields = strings.Split(fields, ",") + for i, field := range query.Fields { + query.Fields[i] = strings.TrimSpace(field) + } + } + } + if limit := values.Get("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil { + if l > 0 && l <= qp.maxLimit { + query.Limit = l + } + } + } + if offset := values.Get("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + filters, err := qp.parseFilters(values) + if err != nil { + return query, err + } + query.Filters = filters + sorts, err := qp.parseSorting(values) + if err != nil { + return query, err + } + query.Sort = sorts + if groupBy := values.Get("group"); groupBy != "" { + query.GroupBy = strings.Split(groupBy, ",") + for i, field := range query.GroupBy { + query.GroupBy[i] = strings.TrimSpace(field) + } + } + return query, nil +} + +// parseFilters, parseSorting, ParseAdvancedFilters, parseDate, parseNumeric remain the same +func (qp *QueryParser) 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 +} + +func (qp *QueryParser) 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 +} + +func (qp *QueryParser) ParseAdvancedFilters(filterParam string) ([]FilterGroup, error) { + return nil, nil +} +func parseDate(value string) (interface{}, error) { + formats := []string{"2006-01-02", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05.000Z", "2006-01-02 15:04:05"} + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t, nil + } + } + return value, nil +} +func parseNumeric(value string) interface{} { + if i, err := strconv.Atoi(value); err == nil { + return i + } + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f + } + return value +} diff --git a/internal/utils/query/exemple.go b/internal/utils/query/exemple.go new file mode 100644 index 00000000..4d53815d --- /dev/null +++ b/internal/utils/query/exemple.go @@ -0,0 +1,220 @@ +package main + +import ( + "fmt" + "log" + "net/url" + + "your_module_path/utils" // Ganti dengan path modul Anda +) + +func main() { + // --- 1. Setup QueryBuilder --- + // Kita akan menggunakan PostgreSQL untuk contoh ini. + // Untuk database lain, cukup ganti DBTypePostgreSQL menjadi DBTypeMySQL, dll. + qb := utils.NewQueryBuilder("users", utils.DBTypePostgreSQL). + SetColumnMapping(map[string]string{ + // API Field -> DB Column + "userId": "id", + "username": "user_name", + "email": "email_address", + "isActive": "is_active", + "createdAt": "created_at", + }). + SetAllowedColumns([]string{ + "id", "user_name", "email_address", "is_active", "created_at", "updated_at", + }) + + fmt.Println("=== QUERY BUILDER EXAMPLES ===\n") + + // --- 2. CREATE (INSERT) --- + fmt.Println("--- CREATE (INSERT) ---") + + // Contoh data user baru + newUser := utils.InsertData{ + Columns: []string{"username", "email", "is_active"}, + Values: []interface{}{"john_doe", "john.doe@example.com", true}, + } + + // Build INSERT query + sql, args, err := qb.BuildInsertQuery(newUser, "id", "created_at") + if err != nil { + log.Fatalf("Failed to build INSERT query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: INSERT INTO users (user_name, email_address, is_active) VALUES ($1, $2, $3) RETURNING id, created_at + // Args: [john_doe john.doe@example.com true] + + // --- 3. UPDATE --- + fmt.Println("--- UPDATE ---") + + // Data yang akan diupdate untuk user dengan ID 1 + updateData := utils.UpdateData{ + Columns: []string{"username", "is_active"}, + Values: []interface{}{"john_doe_updated", false}, + } + + // Filter untuk menentukan user mana yang akan diupdate + filters := []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "userId", + Operator: utils.OpEqual, + Value: 1, + }}, + }} + + // Build UPDATE query + sql, args, err = qb.BuildUpdateQuery(updateData, filters, "updated_at") + if err != nil { + log.Fatalf("Failed to build UPDATE query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: UPDATE "users" SET "user_name" = $1, "is_active" = $2 WHERE ("id" = $3) RETURNING updated_at + // Args: [john_doe_updated false 1] + + // --- 4. DELETE --- + fmt.Println("--- DELETE ---") + + // Filter untuk menghapus user yang tidak aktif + deleteFilters := []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "isActive", + Operator: utils.OpEqual, + Value: false, + }}, + }} + + // Build DELETE query + sql, args, err = qb.BuildDeleteQuery(deleteFilters, "id") + if err != nil { + log.Fatalf("Failed to build DELETE query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: DELETE FROM "users" WHERE ("is_active" = $1) RETURNING id + // Args: [false] + + // --- 5. UPSERT (INSERT ... ON CONFLICT) --- + fmt.Println("--- UPSERT ---") + + // Data untuk upsert (insert atau update jika sudah ada) + upsertData := utils.InsertData{ + Columns: []string{"id", "username", "email"}, + Values: []interface{}{1, "unique_user", "unique@example.com"}, // ID 1 mungkin sudah ada + } + + // Kolom yang menjadi penentu konflik (misalnya, primary key atau unique key) + conflictColumns := []string{"id"} + + // Data yang akan diupdate jika terjadi konflik + upsertUpdateData := utils.UpdateData{ + Columns: []string{"username", "email"}, + Values: []interface{}{"unique_user_updated", "updated@example.com"}, + } + + // Build UPSERT query + sql, args, err = qb.BuildUpsertQuery(upsertData, conflictColumns, upsertUpdateData, "updated_at") + if err != nil { + log.Fatalf("Failed to build UPSERT query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: INSERT INTO users (id, user_name, email_address) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET "user_name" = EXCLUDED.user_name, "email_address" = EXCLUDED.email_address RETURNING updated_at + // Args: [1 unique_user unique@example.com unique_user_updated updated@example.com] + + // --- 6. SELECT dengan JOIN --- + fmt.Println("--- SELECT with JOIN ---") + + // Reset builder untuk query baru + qbJoin := utils.NewQueryBuilder("users", utils.DBTypePostgreSQL). + SetColumnMapping(map[string]string{"userId": "id", "username": "user_name"}). + SetAllowedColumns([]string{"id", "user_name", "profile_id"}). + Join("INNER", "profiles", "users.id = profiles.user_id") // Tambahkan JOIN + + // Query untuk mendapatkan user dan profilnya + joinQuery := utils.DynamicQuery{ + Fields: []string{"users.id", "user_name", "profiles.bio"}, + Filters: []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "isActive", + Operator: utils.OpEqual, + Value: true, + }}, + }}, + Limit: 10, + } + + sql, args, err = qbJoin.BuildQuery(joinQuery) + if err != nil { + log.Fatalf("Failed to build JOIN query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: SELECT "users"."id", "user_name", "profiles"."bio" FROM "users" INNER JOIN "profiles" ON users.id = profiles.user_id WHERE ("is_active" = $1) LIMIT 10 + // Args: [true] + + // --- 7. SELECT dengan UNION --- + fmt.Println("--- SELECT with UNION ---") + + // Buat dua query builder untuk dua tabel berbeda + qbActive := utils.NewQueryBuilder("users", utils.DBTypePostgreSQL) + qbArchived := utils.NewQueryBuilder("archived_users", utils.DBTypePostgreSQL) + + // Query pertama: user aktif dari tabel 'users' + query1 := qbActive.sqlBuilder.Select("id", "user_name", "'active' as status").Where("is_active = ?", true) + + // Query kedua: user aktif dari tabel 'archived_users' + query2 := qbArchived.sqlBuilder.Select("id", "user_name", "'archived' as status").Where("is_active = ?", true) + + // Gabungkan dengan UNION + unionQuery := query1.Union(query2) + + sql, args, err = unionQuery.ToSql() + if err != nil { + log.Fatalf("Failed to build UNION query: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: SELECT id, user_name, 'active' as status FROM users WHERE is_active = $1 UNION SELECT id, user_name, 'archived' as status FROM archived_users WHERE is_active = $2 + // Args: [true true] + + // --- 8. Integrasi dengan QueryParser (Web Context) --- + fmt.Println("--- Integration with QueryParser ---") + + // Simulasi query parameter dari URL: /users?fields=id,username&filter[username][_contains]=john&sort=-createdAt&limit=5 + mockURLValues := url.Values{ + "fields": []string{"id", "username"}, + "filter[username][_contains]": []string{"john"}, + "sort": []string{"-createdAt"}, + "limit": []string{"5"}, + } + + parser := utils.NewQueryParser() + dynamicQuery, err := parser.ParseQuery(mockURLValues) + if err != nil { + log.Fatalf("Failed to parse query: %v", err) + } + + // Build query dari hasil parsing + qbParser := utils.NewQueryBuilder("users", utils.DBTypePostgreSQL). + SetColumnMapping(map[string]string{"username": "user_name", "createdAt": "created_at"}). + SetAllowedColumns([]string{"id", "user_name", "created_at"}) + + sql, args, err = qbParser.BuildQuery(dynamicQuery) + if err != nil { + log.Fatalf("Failed to build query from parser: %v", err) + } + fmt.Printf("SQL: %s\n", sql) + fmt.Printf("Args: %v\n\n", args) + // Output (PostgreSQL): + // SQL: SELECT "id", "user_name" FROM "users" WHERE ("user_name" ILIKE $1) ORDER BY "created_at" DESC LIMIT 5 + // Args: [%john%] +}