first commit
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
||||
// Builder provides fluent API for building errors
|
||||
type Builder struct {
|
||||
code string
|
||||
message string
|
||||
category string
|
||||
httpStatus int
|
||||
grpcCode codes.Code
|
||||
metadata Metadata
|
||||
cause error
|
||||
}
|
||||
|
||||
// NewBuilder creates a new error builder
|
||||
func NewBuilder() *Builder {
|
||||
return &Builder{
|
||||
metadata: make(Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Code sets error code
|
||||
func (b *Builder) Code(code string) *Builder {
|
||||
b.code = code
|
||||
info := GetErrorInfo(code)
|
||||
b.category = info.Category
|
||||
b.httpStatus = info.HTTPStatus
|
||||
b.grpcCode = info.GRPCCode
|
||||
return b
|
||||
}
|
||||
|
||||
// Message sets error message
|
||||
func (b *Builder) Message(message string) *Builder {
|
||||
b.message = message
|
||||
return b
|
||||
}
|
||||
|
||||
// Category sets error category
|
||||
func (b *Builder) Category(category string) *Builder {
|
||||
b.category = category
|
||||
return b
|
||||
}
|
||||
|
||||
// HTTPStatus sets HTTP status code
|
||||
func (b *Builder) HTTPStatus(status int) *Builder {
|
||||
b.httpStatus = status
|
||||
return b
|
||||
}
|
||||
|
||||
// GRPCCode sets gRPC code
|
||||
func (b *Builder) GRPCCode(code codes.Code) *Builder {
|
||||
b.grpcCode = code
|
||||
return b
|
||||
}
|
||||
|
||||
// Metadata adds metadata
|
||||
func (b *Builder) Metadata(key string, value interface{}) *Builder {
|
||||
if b.metadata == nil {
|
||||
b.metadata = make(Metadata)
|
||||
}
|
||||
b.metadata[key] = value
|
||||
return b
|
||||
}
|
||||
|
||||
// Metadatas adds multiple metadata
|
||||
func (b *Builder) Metadatas(metadata Metadata) *Builder {
|
||||
if b.metadata == nil {
|
||||
b.metadata = make(Metadata)
|
||||
}
|
||||
for k, v := range metadata {
|
||||
b.metadata[k] = v
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Cause sets underlying error
|
||||
func (b *Builder) Cause(err error) *Builder {
|
||||
b.cause = err
|
||||
return b
|
||||
}
|
||||
|
||||
// Timestamp adds timestamp
|
||||
func (b *Builder) Timestamp(t time.Time) *Builder {
|
||||
b.Metadata("timestamp", t.Format(time.RFC3339))
|
||||
return b
|
||||
}
|
||||
|
||||
// RequestID adds request ID
|
||||
func (b *Builder) RequestID(id string) *Builder {
|
||||
b.Metadata("request_id", id)
|
||||
return b
|
||||
}
|
||||
|
||||
// UserID adds user ID
|
||||
func (b *Builder) UserID(id string) *Builder {
|
||||
b.Metadata("user_id", id)
|
||||
return b
|
||||
}
|
||||
|
||||
// Service adds service name
|
||||
func (b *Builder) Service(name string) *Builder {
|
||||
b.Metadata("service", name)
|
||||
return b
|
||||
}
|
||||
|
||||
// Operation adds operation name
|
||||
func (b *Builder) Operation(name string) *Builder {
|
||||
b.Metadata("operation", name)
|
||||
return b
|
||||
}
|
||||
|
||||
// Retryable sets retryable flag
|
||||
func (b *Builder) Retryable(retryable bool) *Builder {
|
||||
b.Metadata("retryable", retryable)
|
||||
return b
|
||||
}
|
||||
|
||||
// Severity sets error severity
|
||||
func (b *Builder) Severity(severity string) *Builder {
|
||||
b.Metadata("severity", severity)
|
||||
return b
|
||||
}
|
||||
|
||||
// Component adds component name
|
||||
func (b *Builder) Component(name string) *Builder {
|
||||
b.Metadata("component", name)
|
||||
return b
|
||||
}
|
||||
|
||||
// Build creates the final error
|
||||
func (b *Builder) Build() Error {
|
||||
// Use defaults if not set
|
||||
if b.code == "" {
|
||||
b.code = ErrCodeInternalError
|
||||
}
|
||||
if b.message == "" {
|
||||
b.message = "An error occurred"
|
||||
}
|
||||
if b.category == "" {
|
||||
b.category = CategoryInternal
|
||||
}
|
||||
if b.httpStatus == 0 {
|
||||
b.httpStatus = http.StatusInternalServerError
|
||||
}
|
||||
if b.grpcCode == codes.OK {
|
||||
b.grpcCode = codes.Internal
|
||||
}
|
||||
if b.metadata == nil {
|
||||
b.metadata = make(Metadata)
|
||||
}
|
||||
if _, exists := b.metadata["timestamp"]; !exists {
|
||||
b.Metadata("timestamp", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
return &AppError{
|
||||
code: b.code,
|
||||
message: b.message,
|
||||
category: b.category,
|
||||
httpStatus: b.httpStatus,
|
||||
grpcCode: b.grpcCode,
|
||||
metadata: b.metadata,
|
||||
cause: b.cause,
|
||||
timestamp: time.Now(),
|
||||
stackTrace: captureStackTrace(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewValidationError creates validation error builder
|
||||
func NewValidationError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeValidationFailed).
|
||||
Category(CategoryValidation).
|
||||
HTTPStatus(http.StatusBadRequest).
|
||||
GRPCCode(codes.InvalidArgument).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// NotFoundError creates not found error builder
|
||||
func NotFoundError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeNotFound).
|
||||
Category(CategoryNotFound).
|
||||
HTTPStatus(http.StatusNotFound).
|
||||
GRPCCode(codes.NotFound).
|
||||
Severity("info")
|
||||
}
|
||||
|
||||
// UnauthorizedError creates unauthorized error builder
|
||||
func UnauthorizedError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeUnauthorized).
|
||||
Category(CategoryUnauthorized).
|
||||
HTTPStatus(http.StatusUnauthorized).
|
||||
GRPCCode(codes.Unauthenticated).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// ForbiddenError creates forbidden error builder
|
||||
func ForbiddenError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeForbidden).
|
||||
Category(CategoryForbidden).
|
||||
HTTPStatus(http.StatusForbidden).
|
||||
GRPCCode(codes.PermissionDenied).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// AlreadyExistsError creates already exists error builder
|
||||
func AlreadyExistsError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeConflict).
|
||||
Category(CategoryConflict).
|
||||
HTTPStatus(http.StatusConflict).
|
||||
GRPCCode(codes.AlreadyExists).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// DuplicateError creates duplicate entry error builder
|
||||
func DuplicateError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeDuplicateEntry).
|
||||
Category(CategoryConflict).
|
||||
HTTPStatus(http.StatusConflict).
|
||||
GRPCCode(codes.AlreadyExists).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// ConflictError creates conflict error builder
|
||||
func ConflictError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeConflict).
|
||||
Category(CategoryConflict).
|
||||
HTTPStatus(http.StatusConflict).
|
||||
GRPCCode(codes.AlreadyExists).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// InternalError creates internal error builder
|
||||
func InternalError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeInternalError).
|
||||
Category(CategoryInternal).
|
||||
HTTPStatus(http.StatusInternalServerError).
|
||||
GRPCCode(codes.Internal).
|
||||
Severity("error")
|
||||
}
|
||||
|
||||
// ExternalError creates external error builder
|
||||
func ExternalError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeExternalError).
|
||||
Category(CategoryExternal).
|
||||
HTTPStatus(http.StatusBadGateway).
|
||||
GRPCCode(codes.Unavailable).
|
||||
Severity("error").
|
||||
Retryable(true)
|
||||
}
|
||||
|
||||
// TimeoutError creates timeout error builder
|
||||
func TimeoutError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeTimeout).
|
||||
Category(CategoryTimeout).
|
||||
HTTPStatus(http.StatusRequestTimeout).
|
||||
GRPCCode(codes.DeadlineExceeded).
|
||||
Severity("warning").
|
||||
Retryable(true)
|
||||
}
|
||||
|
||||
// BusinessError creates business error builder
|
||||
func BusinessError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeBusinessRule).
|
||||
Category(CategoryBusiness).
|
||||
HTTPStatus(http.StatusBadRequest).
|
||||
GRPCCode(codes.FailedPrecondition).
|
||||
Severity("warning")
|
||||
}
|
||||
|
||||
// DatabaseError creates database error builder
|
||||
func DatabaseError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeDatabaseError).
|
||||
Category(CategoryDatabase).
|
||||
HTTPStatus(http.StatusInternalServerError).
|
||||
GRPCCode(codes.Internal).
|
||||
Severity("error")
|
||||
}
|
||||
|
||||
// NetworkError creates network error builder
|
||||
func NetworkError() *Builder {
|
||||
return NewBuilder().
|
||||
Code(ErrCodeNetworkError).
|
||||
Category(CategoryNetwork).
|
||||
HTTPStatus(http.StatusBadGateway).
|
||||
GRPCCode(codes.Unavailable).
|
||||
Severity("error").
|
||||
Retryable(true)
|
||||
}
|
||||
|
||||
// CustomError creates custom error builder
|
||||
func CustomError(code, message string) *Builder {
|
||||
return NewBuilder().
|
||||
Code(code).
|
||||
Message(message)
|
||||
}
|
||||
|
||||
// FromError creates builder from existing error
|
||||
func FromErrorBuilder(err error) *Builder {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
return NewBuilder()
|
||||
}
|
||||
|
||||
return NewBuilder().
|
||||
Code(appErr.Code()).
|
||||
Message(appErr.Error()).
|
||||
Category(appErr.Category()).
|
||||
HTTPStatus(appErr.HTTPStatus()).
|
||||
GRPCCode(appErr.GRPCCode()).
|
||||
Metadatas(appErr.Metadata()).
|
||||
Cause(appErr.Cause())
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
||||
// Error categories
|
||||
const (
|
||||
CategoryValidation = "validation"
|
||||
CategoryNotFound = "not_found"
|
||||
CategoryUnauthorized = "unauthorized"
|
||||
CategoryForbidden = "forbidden"
|
||||
CategoryConflict = "conflict"
|
||||
CategoryRateLimit = "rate_limit"
|
||||
CategoryInternal = "internal"
|
||||
CategoryExternal = "external"
|
||||
CategoryBusiness = "business"
|
||||
CategoryTimeout = "timeout"
|
||||
CategoryNetwork = "network"
|
||||
CategoryDatabase = "database"
|
||||
)
|
||||
|
||||
// Standard error codes
|
||||
const (
|
||||
// Validation errors
|
||||
ErrCodeValidationFailed = "VALIDATION_FAILED"
|
||||
ErrCodeInvalidInput = "INVALID_INPUT"
|
||||
ErrCodeMissingField = "MISSING_FIELD"
|
||||
ErrCodeInvalidFormat = "INVALID_FORMAT"
|
||||
ErrCodeInvalidLength = "INVALID_LENGTH"
|
||||
ErrCodeInvalidRange = "INVALID_RANGE"
|
||||
|
||||
// Not found errors
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeUserNotFound = "USER_NOT_FOUND"
|
||||
ErrCodeResourceNotFound = "RESOURCE_NOT_FOUND"
|
||||
ErrCodeDataNotFound = "DATA_NOT_FOUND"
|
||||
|
||||
// Authorization errors
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeInvalidToken = "INVALID_TOKEN"
|
||||
ErrCodeTokenExpired = "TOKEN_EXPIRED"
|
||||
ErrCodeInvalidCredentials = "INVALID_CREDENTIALS"
|
||||
|
||||
// Forbidden errors
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeInsufficientRights = "INSUFFICIENT_RIGHTS"
|
||||
ErrCodeAccessDenied = "ACCESS_DENIED"
|
||||
|
||||
// Conflict errors
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeDuplicateEntry = "DUPLICATE_ENTRY"
|
||||
ErrCodeResourceLocked = "RESOURCE_LOCKED"
|
||||
ErrCodeConcurrentUpdate = "CONCURRENT_UPDATE"
|
||||
|
||||
// Rate limit errors
|
||||
ErrCodeRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrCodeTooManyRequests = "TOO_MANY_REQUESTS"
|
||||
ErrCodeQuotaExceeded = "QUOTA_EXCEEDED"
|
||||
|
||||
// Internal errors
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeUnexpectedError = "UNEXPECTED_ERROR"
|
||||
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
ErrCodeConfigurationError = "CONFIGURATION_ERROR"
|
||||
|
||||
// External errors
|
||||
ErrCodeExternalError = "EXTERNAL_ERROR"
|
||||
ErrCodeThirdPartyError = "THIRD_PARTY_ERROR"
|
||||
ErrCodeAPIError = "API_ERROR"
|
||||
|
||||
// Business errors
|
||||
ErrCodeBusinessRule = "BUSINESS_RULE"
|
||||
ErrCodeInsufficientBalance = "INSUFFICIENT_BALANCE"
|
||||
ErrCodeAccountSuspended = "ACCOUNT_SUSPENDED"
|
||||
|
||||
// Timeout errors
|
||||
ErrCodeTimeout = "TIMEOUT"
|
||||
ErrCodeRequestTimeout = "REQUEST_TIMEOUT"
|
||||
ErrCodeConnectionTimeout = "CONNECTION_TIMEOUT"
|
||||
|
||||
// Network errors
|
||||
ErrCodeNetworkError = "NETWORK_ERROR"
|
||||
ErrCodeConnectionFailed = "CONNECTION_FAILED"
|
||||
ErrCodeConnectionLost = "CONNECTION_LOST"
|
||||
|
||||
// Database errors
|
||||
ErrCodeDatabaseError = "DATABASE_ERROR"
|
||||
ErrCodeQueryFailed = "QUERY_FAILED"
|
||||
ErrCodeTransactionFailed = "TRANSACTION_FAILED"
|
||||
)
|
||||
|
||||
// ErrorInfo contains error metadata
|
||||
type ErrorInfo struct {
|
||||
Code string
|
||||
Category string
|
||||
HTTPStatus int
|
||||
GRPCCode codes.Code
|
||||
}
|
||||
|
||||
// ErrorTemplate for custom error types
|
||||
type ErrorTemplate struct {
|
||||
Code string
|
||||
Message string
|
||||
Category string
|
||||
HTTPStatus int
|
||||
GRPCCode codes.Code
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
var (
|
||||
errorCodes = make(map[string]ErrorInfo)
|
||||
errorCodesMu sync.RWMutex
|
||||
errorTemplates = make(map[string]*ErrorTemplate)
|
||||
errorTemplatesMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize standard error codes
|
||||
initializeErrorCodes()
|
||||
}
|
||||
|
||||
// initializeErrorCodes sets up standard error codes
|
||||
func initializeErrorCodes() {
|
||||
// Validation errors
|
||||
registerErrorCode(ErrCodeValidationFailed, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
registerErrorCode(ErrCodeInvalidInput, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
registerErrorCode(ErrCodeMissingField, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
registerErrorCode(ErrCodeInvalidFormat, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
registerErrorCode(ErrCodeInvalidLength, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
registerErrorCode(ErrCodeInvalidRange, CategoryValidation, http.StatusBadRequest, codes.InvalidArgument)
|
||||
|
||||
// Not found errors
|
||||
registerErrorCode(ErrCodeNotFound, CategoryNotFound, http.StatusNotFound, codes.NotFound)
|
||||
registerErrorCode(ErrCodeUserNotFound, CategoryNotFound, http.StatusNotFound, codes.NotFound)
|
||||
registerErrorCode(ErrCodeResourceNotFound, CategoryNotFound, http.StatusNotFound, codes.NotFound)
|
||||
registerErrorCode(ErrCodeDataNotFound, CategoryNotFound, http.StatusNotFound, codes.NotFound)
|
||||
|
||||
// Authorization errors
|
||||
registerErrorCode(ErrCodeUnauthorized, CategoryUnauthorized, http.StatusUnauthorized, codes.Unauthenticated)
|
||||
registerErrorCode(ErrCodeInvalidToken, CategoryUnauthorized, http.StatusUnauthorized, codes.Unauthenticated)
|
||||
registerErrorCode(ErrCodeTokenExpired, CategoryUnauthorized, http.StatusUnauthorized, codes.Unauthenticated)
|
||||
registerErrorCode(ErrCodeInvalidCredentials, CategoryUnauthorized, http.StatusUnauthorized, codes.Unauthenticated)
|
||||
|
||||
// Forbidden errors
|
||||
registerErrorCode(ErrCodeForbidden, CategoryForbidden, http.StatusForbidden, codes.PermissionDenied)
|
||||
registerErrorCode(ErrCodeInsufficientRights, CategoryForbidden, http.StatusForbidden, codes.PermissionDenied)
|
||||
registerErrorCode(ErrCodeAccessDenied, CategoryForbidden, http.StatusForbidden, codes.PermissionDenied)
|
||||
|
||||
// Conflict errors
|
||||
registerErrorCode(ErrCodeConflict, CategoryConflict, http.StatusConflict, codes.AlreadyExists)
|
||||
registerErrorCode(ErrCodeDuplicateEntry, CategoryConflict, http.StatusConflict, codes.AlreadyExists)
|
||||
registerErrorCode(ErrCodeResourceLocked, CategoryConflict, http.StatusLocked, codes.Aborted)
|
||||
registerErrorCode(ErrCodeConcurrentUpdate, CategoryConflict, http.StatusConflict, codes.Aborted)
|
||||
|
||||
// Rate limit errors
|
||||
registerErrorCode(ErrCodeRateLimitExceeded, CategoryRateLimit, http.StatusTooManyRequests, codes.ResourceExhausted)
|
||||
registerErrorCode(ErrCodeTooManyRequests, CategoryRateLimit, http.StatusTooManyRequests, codes.ResourceExhausted)
|
||||
registerErrorCode(ErrCodeQuotaExceeded, CategoryRateLimit, http.StatusTooManyRequests, codes.ResourceExhausted)
|
||||
|
||||
// Internal errors
|
||||
registerErrorCode(ErrCodeInternalError, CategoryInternal, http.StatusInternalServerError, codes.Internal)
|
||||
registerErrorCode(ErrCodeUnexpectedError, CategoryInternal, http.StatusInternalServerError, codes.Internal)
|
||||
registerErrorCode(ErrCodeServiceUnavailable, CategoryInternal, http.StatusServiceUnavailable, codes.Unavailable)
|
||||
registerErrorCode(ErrCodeConfigurationError, CategoryInternal, http.StatusInternalServerError, codes.Internal)
|
||||
|
||||
// External errors
|
||||
registerErrorCode(ErrCodeExternalError, CategoryExternal, http.StatusBadGateway, codes.Unavailable)
|
||||
registerErrorCode(ErrCodeThirdPartyError, CategoryExternal, http.StatusBadGateway, codes.Unavailable)
|
||||
registerErrorCode(ErrCodeAPIError, CategoryExternal, http.StatusBadGateway, codes.Unavailable)
|
||||
|
||||
// Business errors
|
||||
registerErrorCode(ErrCodeBusinessRule, CategoryBusiness, http.StatusBadRequest, codes.FailedPrecondition)
|
||||
registerErrorCode(ErrCodeInsufficientBalance, CategoryBusiness, http.StatusBadRequest, codes.FailedPrecondition)
|
||||
registerErrorCode(ErrCodeAccountSuspended, CategoryBusiness, http.StatusForbidden, codes.PermissionDenied)
|
||||
|
||||
// Timeout errors
|
||||
registerErrorCode(ErrCodeTimeout, CategoryTimeout, http.StatusRequestTimeout, codes.DeadlineExceeded)
|
||||
registerErrorCode(ErrCodeRequestTimeout, CategoryTimeout, http.StatusRequestTimeout, codes.DeadlineExceeded)
|
||||
registerErrorCode(ErrCodeConnectionTimeout, CategoryTimeout, http.StatusRequestTimeout, codes.DeadlineExceeded)
|
||||
|
||||
// Network errors
|
||||
registerErrorCode(ErrCodeNetworkError, CategoryNetwork, http.StatusBadGateway, codes.Unavailable)
|
||||
registerErrorCode(ErrCodeConnectionFailed, CategoryNetwork, http.StatusBadGateway, codes.Unavailable)
|
||||
registerErrorCode(ErrCodeConnectionLost, CategoryNetwork, http.StatusBadGateway, codes.Unavailable)
|
||||
|
||||
// Database errors
|
||||
registerErrorCode(ErrCodeDatabaseError, CategoryDatabase, http.StatusInternalServerError, codes.Internal)
|
||||
registerErrorCode(ErrCodeQueryFailed, CategoryDatabase, http.StatusInternalServerError, codes.Internal)
|
||||
registerErrorCode(ErrCodeTransactionFailed, CategoryDatabase, http.StatusInternalServerError, codes.Aborted)
|
||||
}
|
||||
|
||||
// registerErrorCode registers an error code
|
||||
func registerErrorCode(code, category string, httpStatus int, grpcCode codes.Code) {
|
||||
errorCodesMu.Lock()
|
||||
defer errorCodesMu.Unlock()
|
||||
errorCodes[code] = ErrorInfo{
|
||||
Code: code,
|
||||
Category: category,
|
||||
HTTPStatus: httpStatus,
|
||||
GRPCCode: grpcCode,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterErrorCode registers a custom error code
|
||||
func RegisterErrorCode(code, category string, httpStatus int, grpcCode codes.Code) {
|
||||
registerErrorCode(code, category, httpStatus, grpcCode)
|
||||
}
|
||||
|
||||
// GetErrorInfo returns error information for a code
|
||||
func GetErrorInfo(code string) ErrorInfo {
|
||||
errorCodesMu.RLock()
|
||||
defer errorCodesMu.RUnlock()
|
||||
|
||||
if info, exists := errorCodes[code]; exists {
|
||||
return info
|
||||
}
|
||||
|
||||
return ErrorInfo{
|
||||
Code: code,
|
||||
Category: CategoryInternal,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
GRPCCode: codes.Internal,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterErrorTemplate registers a custom error template
|
||||
func RegisterErrorTemplate(name string, template *ErrorTemplate) {
|
||||
errorTemplatesMu.Lock()
|
||||
defer errorTemplatesMu.Unlock()
|
||||
errorTemplates[name] = template
|
||||
}
|
||||
|
||||
// GetErrorTemplate returns error template by name
|
||||
func GetErrorTemplate(name string) *ErrorTemplate {
|
||||
errorTemplatesMu.RLock()
|
||||
defer errorTemplatesMu.RUnlock()
|
||||
return errorTemplates[name]
|
||||
}
|
||||
|
||||
// GetAllErrorCodes returns all registered error codes
|
||||
func GetAllErrorCodes() map[string]ErrorInfo {
|
||||
errorCodesMu.RLock()
|
||||
defer errorCodesMu.RUnlock()
|
||||
|
||||
result := make(map[string]ErrorInfo)
|
||||
for k, v := range errorCodes {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetErrorCodesByCategory returns error codes by category
|
||||
func GetErrorCodesByCategory(category string) map[string]ErrorInfo {
|
||||
errorCodesMu.RLock()
|
||||
defer errorCodesMu.RUnlock()
|
||||
|
||||
result := make(map[string]ErrorInfo)
|
||||
for k, v := range errorCodes {
|
||||
if v.Category == category {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsRetryable checks if error is retryable
|
||||
func IsRetryable(code string) bool {
|
||||
info := GetErrorInfo(code)
|
||||
switch info.Category {
|
||||
case CategoryNetwork, CategoryTimeout, CategoryExternal:
|
||||
return true
|
||||
case CategoryDatabase:
|
||||
return strings.Contains(code, "TIMEOUT") || strings.Contains(code, "CONNECTION")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsClientError checks if error is client-side (4xx)
|
||||
func IsClientError(code string) bool {
|
||||
info := GetErrorInfo(code)
|
||||
return info.HTTPStatus >= 400 && info.HTTPStatus < 500
|
||||
}
|
||||
|
||||
// IsServerError checks if error is server-side (5xx)
|
||||
func IsServerError(code string) bool {
|
||||
info := GetErrorInfo(code)
|
||||
return info.HTTPStatus >= 500
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
||||
// Core error interface
|
||||
type Error interface {
|
||||
Error() string
|
||||
Code() string
|
||||
Category() string
|
||||
HTTPStatus() int
|
||||
GRPCCode() codes.Code
|
||||
Metadata() map[string]interface{}
|
||||
Cause() error
|
||||
GetLocalizedMessage(lang string) string
|
||||
WithMetadata(key string, value interface{}) Error
|
||||
WithCause(err error) Error
|
||||
}
|
||||
|
||||
// Metadata type for error context
|
||||
type Metadata map[string]interface{}
|
||||
|
||||
// Core error implementation
|
||||
type AppError struct {
|
||||
code string
|
||||
message string
|
||||
category string
|
||||
httpStatus int
|
||||
grpcCode codes.Code
|
||||
metadata Metadata
|
||||
cause error
|
||||
timestamp time.Time
|
||||
stackTrace []string
|
||||
}
|
||||
|
||||
// New creates a new error
|
||||
func New(message string) Error {
|
||||
return &AppError{
|
||||
code: ErrCodeInternalError,
|
||||
message: message,
|
||||
category: CategoryInternal,
|
||||
httpStatus: http.StatusInternalServerError,
|
||||
grpcCode: codes.Internal,
|
||||
metadata: make(Metadata),
|
||||
timestamp: time.Now(),
|
||||
stackTrace: captureStackTrace(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithCode creates a new error with code
|
||||
func NewWithCode(code, message string) Error {
|
||||
info := GetErrorInfo(code)
|
||||
if info.Code == "" {
|
||||
info = ErrorInfo{
|
||||
Code: code,
|
||||
Category: CategoryInternal,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
GRPCCode: codes.Internal,
|
||||
}
|
||||
}
|
||||
|
||||
return &AppError{
|
||||
code: code,
|
||||
message: message,
|
||||
category: info.Category,
|
||||
httpStatus: info.HTTPStatus,
|
||||
grpcCode: info.GRPCCode,
|
||||
metadata: make(Metadata),
|
||||
timestamp: time.Now(),
|
||||
stackTrace: captureStackTrace(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithMetadata creates a new error with metadata
|
||||
func NewWithMetadata(code, message string, metadata Metadata) Error {
|
||||
err := NewWithCode(code, message)
|
||||
err.(*AppError).metadata = metadata
|
||||
return err
|
||||
}
|
||||
|
||||
// NewWithType creates a new error with custom type
|
||||
func NewWithType(errorType string, metadata Metadata) Error {
|
||||
template := GetErrorTemplate(errorType)
|
||||
if template == nil {
|
||||
return NewWithMetadata(ErrCodeInternalError, "Unknown error type", metadata)
|
||||
}
|
||||
|
||||
err := &AppError{
|
||||
code: template.Code,
|
||||
message: template.Message,
|
||||
category: template.Category,
|
||||
httpStatus: template.HTTPStatus,
|
||||
grpcCode: template.GRPCCode,
|
||||
metadata: make(Metadata),
|
||||
timestamp: time.Now(),
|
||||
stackTrace: captureStackTrace(),
|
||||
}
|
||||
|
||||
for k, v := range metadata {
|
||||
err.metadata[k] = v
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap wraps an existing error
|
||||
func Wrap(err error, code, message string) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if IsAppError(err) {
|
||||
return err.(Error)
|
||||
}
|
||||
|
||||
appErr := NewWithCode(code, message)
|
||||
appErr.(*AppError).cause = err
|
||||
return appErr
|
||||
}
|
||||
|
||||
// FromError converts any error to AppError
|
||||
func FromError(err error) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if appErr, ok := err.(Error); ok {
|
||||
return appErr
|
||||
}
|
||||
|
||||
return Wrap(err, ErrCodeInternalError, err.Error())
|
||||
}
|
||||
|
||||
// IsAppError checks if error is AppError
|
||||
func IsAppError(err error) bool {
|
||||
_, ok := err.(Error)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Error implements error interface
|
||||
func (e *AppError) Error() string {
|
||||
if e.cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.message, e.cause)
|
||||
}
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Code returns error code
|
||||
func (e *AppError) Code() string {
|
||||
return e.code
|
||||
}
|
||||
|
||||
// Category returns error category
|
||||
func (e *AppError) Category() string {
|
||||
return e.category
|
||||
}
|
||||
|
||||
// HTTPStatus returns HTTP status code
|
||||
func (e *AppError) HTTPStatus() int {
|
||||
return e.httpStatus
|
||||
}
|
||||
|
||||
// GRPCCode returns gRPC code
|
||||
func (e *AppError) GRPCCode() codes.Code {
|
||||
return e.grpcCode
|
||||
}
|
||||
|
||||
// Metadata returns error metadata
|
||||
func (e *AppError) Metadata() map[string]interface{} {
|
||||
return e.metadata
|
||||
}
|
||||
|
||||
// Cause returns underlying error
|
||||
func (e *AppError) Cause() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// Unwrap implements the standard library unwrap interface for Go 1.13+ error chains
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
// GetLocalizedMessage returns localized message
|
||||
func (e *AppError) GetLocalizedMessage(lang string) string {
|
||||
return GetLocalizedMessage(e.code, lang, e.message)
|
||||
}
|
||||
|
||||
// WithMetadata adds metadata to error
|
||||
func (e *AppError) WithMetadata(key string, value interface{}) Error {
|
||||
if e.metadata == nil {
|
||||
e.metadata = make(Metadata)
|
||||
}
|
||||
e.metadata[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCause adds cause to error
|
||||
func (e *AppError) WithCause(err error) Error {
|
||||
e.cause = err
|
||||
return e
|
||||
}
|
||||
|
||||
// ToJSON converts error to JSON
|
||||
func (e *AppError) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(map[string]interface{}{
|
||||
"code": e.code,
|
||||
"message": e.message,
|
||||
"category": e.category,
|
||||
"http_status": e.httpStatus,
|
||||
"grpc_code": e.grpcCode.String(),
|
||||
"metadata": e.metadata,
|
||||
"timestamp": e.timestamp,
|
||||
"stack_trace": e.stackTrace,
|
||||
})
|
||||
}
|
||||
|
||||
// captureStackTrace captures current stack trace
|
||||
func captureStackTrace() []string {
|
||||
var stack []string
|
||||
for i := 2; i < 15; i++ {
|
||||
pc, file, line, ok := runtime.Caller(i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
fn := runtime.FuncForPC(pc)
|
||||
stack = append(stack, fmt.Sprintf("%s:%d %s", file, line, fn.Name()))
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
// Is checks if error matches target
|
||||
func Is(err, target error) bool {
|
||||
if err == target {
|
||||
return true
|
||||
}
|
||||
|
||||
if appErr, ok := err.(Error); ok {
|
||||
if targetErr, ok := target.(Error); ok {
|
||||
return appErr.Code() == targetErr.Code()
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// As finds the first error in err's chain that matches target
|
||||
func As(err error, target interface{}) bool {
|
||||
if appErr, ok := err.(Error); ok {
|
||||
switch t := target.(type) {
|
||||
case **AppError:
|
||||
*t = appErr.(*AppError)
|
||||
return true
|
||||
case *Error:
|
||||
*t = appErr
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"service/pkg/logger"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// GRPCErrorInterceptor creates unary interceptor for error handling
|
||||
func GRPCUnaryInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
return nil, HandleGRPCError(err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GRPCStreamInterceptor creates stream interceptor for error handling
|
||||
func GRPCStreamInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
err := handler(srv, ss)
|
||||
if err != nil {
|
||||
return HandleGRPCError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGRPCError converts error to gRPC error
|
||||
func HandleGRPCError(err error) error {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
return status.Error(codes.Internal, "Internal server error")
|
||||
}
|
||||
|
||||
// Log gRPC error
|
||||
LogGRPCError(appErr)
|
||||
|
||||
// Convert to gRPC status
|
||||
return status.Error(appErr.GRPCCode(), appErr.GetLocalizedMessage("en"))
|
||||
}
|
||||
|
||||
// LogGRPCError logs gRPC error
|
||||
func LogGRPCError(err Error) {
|
||||
log := logger.Default()
|
||||
|
||||
log.Error("gRPC error occurred",
|
||||
logger.String("error_code", err.Code()),
|
||||
logger.String("category", err.Category()),
|
||||
logger.String("grpc_code", err.GRPCCode().String()),
|
||||
logger.Any("metadata", err.Metadata()),
|
||||
)
|
||||
}
|
||||
|
||||
// FromGRPCError converts gRPC error to AppError
|
||||
func FromGRPCError(err error) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return NewWithCode(ErrCodeInternalError, err.Error())
|
||||
}
|
||||
|
||||
// Map gRPC codes to error codes
|
||||
code := mapGRPCToErrorCode(st.Code())
|
||||
appErr := NewWithCode(code, st.Message())
|
||||
|
||||
// Add gRPC metadata
|
||||
appErr = appErr.WithMetadata("grpc_code", st.Code().String())
|
||||
|
||||
return appErr
|
||||
}
|
||||
|
||||
// mapGRPCToErrorCode maps gRPC codes to error codes
|
||||
func mapGRPCToErrorCode(grpcCode codes.Code) string {
|
||||
switch grpcCode {
|
||||
case codes.OK:
|
||||
return ""
|
||||
case codes.Canceled:
|
||||
return ErrCodeTimeout
|
||||
case codes.Unknown:
|
||||
return ErrCodeInternalError
|
||||
case codes.InvalidArgument:
|
||||
return ErrCodeValidationFailed
|
||||
case codes.DeadlineExceeded:
|
||||
return ErrCodeTimeout
|
||||
case codes.NotFound:
|
||||
return ErrCodeNotFound
|
||||
case codes.AlreadyExists:
|
||||
return ErrCodeDuplicateEntry
|
||||
case codes.PermissionDenied:
|
||||
return ErrCodeForbidden
|
||||
case codes.ResourceExhausted:
|
||||
return ErrCodeRateLimitExceeded
|
||||
case codes.FailedPrecondition:
|
||||
return ErrCodeBusinessRule
|
||||
case codes.Aborted:
|
||||
return ErrCodeConflict
|
||||
case codes.OutOfRange:
|
||||
return ErrCodeInvalidRange
|
||||
case codes.Unimplemented:
|
||||
return ErrCodeServiceUnavailable
|
||||
case codes.Internal:
|
||||
return ErrCodeInternalError
|
||||
case codes.Unavailable:
|
||||
return ErrCodeExternalError
|
||||
case codes.DataLoss:
|
||||
return ErrCodeInternalError
|
||||
case codes.Unauthenticated:
|
||||
return ErrCodeUnauthorized
|
||||
default:
|
||||
return ErrCodeInternalError
|
||||
}
|
||||
}
|
||||
|
||||
// GRPCMiddlewareConfig configures gRPC middleware
|
||||
type GRPCMiddlewareConfig struct {
|
||||
SkipMethods []string
|
||||
LogAllErrors bool
|
||||
SanitizeErrors bool
|
||||
DefaultLanguage string
|
||||
EnableMetrics bool
|
||||
CustomCodeMapping map[codes.Code]string
|
||||
}
|
||||
|
||||
// DefaultGRPCMiddlewareConfig returns default gRPC configuration
|
||||
func DefaultGRPCMiddlewareConfig() *GRPCMiddlewareConfig {
|
||||
return &GRPCMiddlewareConfig{
|
||||
SkipMethods: []string{"/grpc.health.v1.Health/Check"},
|
||||
LogAllErrors: true,
|
||||
SanitizeErrors: true,
|
||||
DefaultLanguage: "en",
|
||||
EnableMetrics: true,
|
||||
}
|
||||
}
|
||||
|
||||
// GRPCUnaryInterceptorWithConfig creates interceptor with custom configuration
|
||||
func GRPCUnaryInterceptorWithConfig(config *GRPCMiddlewareConfig) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
// Skip specified methods
|
||||
for _, method := range config.SkipMethods {
|
||||
if info.FullMethod == method {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
return nil, handleGRPCErrorWithConfig(err, config)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleGRPCErrorWithConfig handles gRPC error with custom configuration
|
||||
func handleGRPCErrorWithConfig(err error, config *GRPCMiddlewareConfig) error {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
return status.Error(codes.Internal, "Internal server error")
|
||||
}
|
||||
|
||||
// Apply custom code mapping
|
||||
if customCode, exists := config.CustomCodeMapping[appErr.GRPCCode()]; exists {
|
||||
appErr = NewWithCode(customCode, appErr.Error())
|
||||
}
|
||||
|
||||
// Sanitize error if needed
|
||||
if config.SanitizeErrors {
|
||||
appErr = sanitizeError(appErr).(Error)
|
||||
}
|
||||
|
||||
// Log error
|
||||
if config.LogAllErrors {
|
||||
LogGRPCError(appErr)
|
||||
}
|
||||
|
||||
return status.Error(appErr.GRPCCode(), appErr.GetLocalizedMessage(config.DefaultLanguage))
|
||||
}
|
||||
|
||||
// ConvertToHTTPStatus converts gRPC code to HTTP status
|
||||
func ConvertToHTTPStatus(grpcCode codes.Code) int {
|
||||
switch grpcCode {
|
||||
case codes.OK:
|
||||
return http.StatusOK
|
||||
case codes.Canceled:
|
||||
return http.StatusRequestTimeout
|
||||
case codes.Unknown:
|
||||
return http.StatusInternalServerError
|
||||
case codes.InvalidArgument:
|
||||
return http.StatusBadRequest
|
||||
case codes.DeadlineExceeded:
|
||||
return http.StatusRequestTimeout
|
||||
case codes.NotFound:
|
||||
return http.StatusNotFound
|
||||
case codes.AlreadyExists:
|
||||
return http.StatusConflict
|
||||
case codes.PermissionDenied:
|
||||
return http.StatusForbidden
|
||||
case codes.ResourceExhausted:
|
||||
return http.StatusTooManyRequests
|
||||
case codes.FailedPrecondition:
|
||||
return http.StatusBadRequest
|
||||
case codes.Aborted:
|
||||
return http.StatusConflict
|
||||
case codes.OutOfRange:
|
||||
return http.StatusBadRequest
|
||||
case codes.Unimplemented:
|
||||
return http.StatusNotImplemented
|
||||
case codes.Internal:
|
||||
return http.StatusInternalServerError
|
||||
case codes.Unavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
case codes.DataLoss:
|
||||
return http.StatusInternalServerError
|
||||
case codes.Unauthenticated:
|
||||
return http.StatusUnauthorized
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertToGRPCCode converts HTTP status to gRPC code
|
||||
func ConvertToGRPCCode(httpStatus int) codes.Code {
|
||||
switch httpStatus {
|
||||
case http.StatusOK:
|
||||
return codes.OK
|
||||
case http.StatusBadRequest:
|
||||
return codes.InvalidArgument
|
||||
case http.StatusUnauthorized:
|
||||
return codes.Unauthenticated
|
||||
case http.StatusForbidden:
|
||||
return codes.PermissionDenied
|
||||
case http.StatusNotFound:
|
||||
return codes.NotFound
|
||||
case http.StatusConflict:
|
||||
return codes.AlreadyExists
|
||||
case http.StatusTooManyRequests:
|
||||
return codes.ResourceExhausted
|
||||
case http.StatusRequestTimeout:
|
||||
return codes.DeadlineExceeded
|
||||
case http.StatusNotImplemented:
|
||||
return codes.Unimplemented
|
||||
case http.StatusInternalServerError:
|
||||
return codes.Internal
|
||||
case http.StatusServiceUnavailable:
|
||||
return codes.Unavailable
|
||||
case http.StatusBadGateway:
|
||||
return codes.Unavailable
|
||||
default:
|
||||
return codes.Unknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"service/pkg/logger"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTTPErrorResponse represents standardized HTTP error response
|
||||
type HTTPErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
} `json:"error"`
|
||||
Meta struct {
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Category string `json:"category"`
|
||||
} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPMiddleware creates error handling middleware for Gin
|
||||
func HTTPMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// Check if there are any errors
|
||||
if len(c.Errors) > 0 {
|
||||
// Get the last error
|
||||
err := c.Errors.Last().Err
|
||||
HandleHTTPError(c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHTTPError handles HTTP error response
|
||||
func HandleHTTPError(c *gin.Context, err error) {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, "Unknown error")
|
||||
}
|
||||
|
||||
// Create error response
|
||||
response := &HTTPErrorResponse{}
|
||||
response.Error.Code = appErr.Code()
|
||||
response.Error.Message = appErr.GetLocalizedMessage(getLanguageFromContext(c))
|
||||
response.Error.Details = appErr.Metadata()
|
||||
response.Error.RequestID = c.GetString("request_id")
|
||||
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
|
||||
response.Error.Retryable = IsRetryable(appErr.Code())
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
response.Meta.Category = appErr.Category()
|
||||
|
||||
// Log error
|
||||
LogHTTPError(c, appErr)
|
||||
|
||||
// Send response
|
||||
c.JSON(appErr.HTTPStatus(), response)
|
||||
}
|
||||
|
||||
// getLanguageFromContext extracts language from context
|
||||
func getLanguageFromContext(c *gin.Context) string {
|
||||
// Try Accept-Language header
|
||||
lang := c.GetHeader("Accept-Language")
|
||||
if lang != "" {
|
||||
return parseAcceptLanguage(lang)
|
||||
}
|
||||
|
||||
// Try query parameter
|
||||
lang = c.Query("lang")
|
||||
if lang != "" {
|
||||
return lang
|
||||
}
|
||||
|
||||
// Default to English
|
||||
return "en"
|
||||
}
|
||||
|
||||
// parseAcceptLanguage parses Accept-Language header
|
||||
func parseAcceptLanguage(header string) string {
|
||||
// Simple parsing - take first language
|
||||
for idx := 0; idx < len(header); idx++ {
|
||||
if header[idx] == ',' || header[idx] == ';' {
|
||||
return header[:idx]
|
||||
}
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
// LogHTTPError logs HTTP error with context
|
||||
func LogHTTPError(c *gin.Context, err Error) {
|
||||
log := logger.Default()
|
||||
|
||||
log.Error("HTTP error occurred",
|
||||
logger.String("error_code", err.Code()),
|
||||
logger.String("category", err.Category()),
|
||||
logger.Int("http_status", err.HTTPStatus()),
|
||||
logger.Any("metadata", err.Metadata()),
|
||||
logger.String("path", c.Request.URL.Path),
|
||||
logger.String("method", c.Request.Method),
|
||||
)
|
||||
}
|
||||
|
||||
// ValidationErrorHandler handles validation errors specifically
|
||||
func ValidationErrorHandler(c *gin.Context, err error) {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeValidationFailed, err.Error())
|
||||
}
|
||||
|
||||
// Enhance validation errors with field details
|
||||
if appErr.Category() == CategoryValidation {
|
||||
if details, ok := appErr.Metadata()["validation_errors"]; ok {
|
||||
response := gin.H{
|
||||
"error": gin.H{
|
||||
"code": appErr.Code(),
|
||||
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
|
||||
"fields": details,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
HandleHTTPError(c, appErr)
|
||||
}
|
||||
|
||||
// PanicHandler handles panics
|
||||
func PanicHandler(c *gin.Context, recovered interface{}) {
|
||||
var err error
|
||||
|
||||
switch x := recovered.(type) {
|
||||
case string:
|
||||
err = NewWithCode(ErrCodeInternalError, x)
|
||||
case error:
|
||||
err = FromError(x)
|
||||
default:
|
||||
err = NewWithCode(ErrCodeUnexpectedError, fmt.Sprintf("Unknown panic: %v", x))
|
||||
}
|
||||
|
||||
HandleHTTPError(c, err)
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes error response directly
|
||||
func WriteErrorResponse(w http.ResponseWriter, err error) {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, "Unknown error")
|
||||
}
|
||||
|
||||
response := &HTTPErrorResponse{}
|
||||
response.Error.Code = appErr.Code()
|
||||
response.Error.Message = appErr.GetLocalizedMessage("en")
|
||||
response.Error.Details = appErr.Metadata()
|
||||
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
|
||||
response.Error.Retryable = IsRetryable(appErr.Code())
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
response.Meta.Category = appErr.Category()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(appErr.HTTPStatus())
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// sanitizeError removes sensitive information from errors
|
||||
func sanitizeError(err error) error {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove sensitive metadata
|
||||
sanitized := make(Metadata)
|
||||
for k, v := range appErr.Metadata() {
|
||||
if !isSensitiveField(k) {
|
||||
sanitized[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return NewWithMetadata(appErr.Code(), appErr.Error(), sanitized)
|
||||
}
|
||||
|
||||
// isSensitiveField checks if field contains sensitive information
|
||||
func isSensitiveField(field string) bool {
|
||||
sensitiveFields := []string{"password", "token", "secret", "key", "auth"}
|
||||
for _, sensitive := range sensitiveFields {
|
||||
if strings.Contains(strings.ToLower(field), sensitive) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID
|
||||
func generateRequestID() string {
|
||||
return fmt.Sprintf("req_%d", time.Now().UnixNano())
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MessageStore stores localized messages
|
||||
type MessageStore struct {
|
||||
messages map[string]map[string]string // language -> code -> message
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
messageStore = &MessageStore{
|
||||
messages: make(map[string]map[string]string),
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize default messages
|
||||
initializeDefaultMessages()
|
||||
}
|
||||
|
||||
// initializeDefaultMessages sets up default error messages
|
||||
func initializeDefaultMessages() {
|
||||
// English messages
|
||||
englishMessages := map[string]string{
|
||||
ErrCodeValidationFailed: "Validation failed",
|
||||
ErrCodeInvalidInput: "Invalid input provided",
|
||||
ErrCodeMissingField: "Required field is missing",
|
||||
ErrCodeInvalidFormat: "Invalid format",
|
||||
ErrCodeInvalidLength: "Invalid length",
|
||||
ErrCodeInvalidRange: "Value out of range",
|
||||
ErrCodeNotFound: "Resource not found",
|
||||
ErrCodeUserNotFound: "User not found",
|
||||
ErrCodeResourceNotFound: "Resource not found",
|
||||
ErrCodeDataNotFound: "Data not found",
|
||||
ErrCodeUnauthorized: "Unauthorized access",
|
||||
ErrCodeInvalidToken: "Invalid token provided",
|
||||
ErrCodeTokenExpired: "Token has expired",
|
||||
ErrCodeInvalidCredentials: "Invalid credentials",
|
||||
ErrCodeForbidden: "Access forbidden",
|
||||
ErrCodeInsufficientRights: "Insufficient permissions",
|
||||
ErrCodeAccessDenied: "Access denied",
|
||||
ErrCodeConflict: "Conflict occurred",
|
||||
ErrCodeDuplicateEntry: "Duplicate entry",
|
||||
ErrCodeResourceLocked: "Resource is locked",
|
||||
ErrCodeConcurrentUpdate: "Concurrent update detected",
|
||||
ErrCodeRateLimitExceeded: "Rate limit exceeded",
|
||||
ErrCodeTooManyRequests: "Too many requests",
|
||||
ErrCodeQuotaExceeded: "Quota exceeded",
|
||||
ErrCodeInternalError: "Internal server error",
|
||||
ErrCodeUnexpectedError: "An unexpected error occurred",
|
||||
ErrCodeServiceUnavailable: "Service is unavailable",
|
||||
ErrCodeConfigurationError: "Configuration error",
|
||||
ErrCodeExternalError: "External service error",
|
||||
ErrCodeThirdPartyError: "Third party service error",
|
||||
ErrCodeAPIError: "API error occurred",
|
||||
ErrCodeBusinessRule: "Business rule violation",
|
||||
ErrCodeInsufficientBalance: "Insufficient balance",
|
||||
ErrCodeAccountSuspended: "Account suspended",
|
||||
ErrCodeTimeout: "Request timeout",
|
||||
ErrCodeRequestTimeout: "Request timeout",
|
||||
ErrCodeConnectionTimeout: "Connection timeout",
|
||||
ErrCodeNetworkError: "Network error",
|
||||
ErrCodeConnectionFailed: "Connection failed",
|
||||
ErrCodeConnectionLost: "Connection lost",
|
||||
ErrCodeDatabaseError: "Database error",
|
||||
ErrCodeQueryFailed: "Query execution failed",
|
||||
ErrCodeTransactionFailed: "Transaction failed",
|
||||
}
|
||||
|
||||
// Indonesian messages
|
||||
indonesianMessages := map[string]string{
|
||||
ErrCodeValidationFailed: "Validasi gagal",
|
||||
ErrCodeInvalidInput: "Input tidak valid",
|
||||
ErrCodeMissingField: "Field wajib tidak ada",
|
||||
ErrCodeInvalidFormat: "Format tidak valid",
|
||||
ErrCodeInvalidLength: "Panjang tidak valid",
|
||||
ErrCodeInvalidRange: "Nilai di luar jangkauan",
|
||||
ErrCodeNotFound: "Sumber daya tidak ditemukan",
|
||||
ErrCodeUserNotFound: "Pengguna tidak ditemukan",
|
||||
ErrCodeResourceNotFound: "Sumber daya tidak ditemukan",
|
||||
ErrCodeDataNotFound: "Data tidak ditemukan",
|
||||
ErrCodeUnauthorized: "Akses tidak sah",
|
||||
ErrCodeInvalidToken: "Token tidak valid",
|
||||
ErrCodeTokenExpired: "Token telah kadaluarsa",
|
||||
ErrCodeInvalidCredentials: "Kredensial tidak valid",
|
||||
ErrCodeForbidden: "Akses dilarang",
|
||||
ErrCodeInsufficientRights: "Izin tidak mencukupi",
|
||||
ErrCodeAccessDenied: "Akses ditolak",
|
||||
ErrCodeConflict: "Terjadi konflik",
|
||||
ErrCodeDuplicateEntry: "Entri duplikat",
|
||||
ErrCodeResourceLocked: "Sumber daya terkunci",
|
||||
ErrCodeConcurrentUpdate: "Pembaruan bersamaan terdeteksi",
|
||||
ErrCodeRateLimitExceeded: "Batas laju terlampaui",
|
||||
ErrCodeTooManyRequests: "Terlalu banyak permintaan",
|
||||
ErrCodeQuotaExceeded: "Kuota terlampaui",
|
||||
ErrCodeInternalError: "Kesalahan server internal",
|
||||
ErrCodeUnexpectedError: "Terjadi kesalahan tak terduga",
|
||||
ErrCodeServiceUnavailable: "Layanan tidak tersedia",
|
||||
ErrCodeConfigurationError: "Kesalahan konfigurasi",
|
||||
ErrCodeExternalError: "Kesalahan layanan eksternal",
|
||||
ErrCodeThirdPartyError: "Kesalahan layanan pihak ketiga",
|
||||
ErrCodeAPIError: "Terjadi kesalahan API",
|
||||
ErrCodeBusinessRule: "Pelanggaran aturan bisnis",
|
||||
ErrCodeInsufficientBalance: "Saldo tidak mencukupi",
|
||||
ErrCodeAccountSuspended: "Akun ditangguhkan",
|
||||
ErrCodeTimeout: "Permintaan timeout",
|
||||
ErrCodeRequestTimeout: "Permintaan timeout",
|
||||
ErrCodeConnectionTimeout: "Koneksi timeout",
|
||||
ErrCodeNetworkError: "Kesalahan jaringan",
|
||||
ErrCodeConnectionFailed: "Koneksi gagal",
|
||||
ErrCodeConnectionLost: "Koneksi terputus",
|
||||
ErrCodeDatabaseError: "Kesalahan database",
|
||||
ErrCodeQueryFailed: "Eksekusi query gagal",
|
||||
ErrCodeTransactionFailed: "Transaksi gagal",
|
||||
}
|
||||
|
||||
// Add messages to store
|
||||
SetMessages("en", englishMessages)
|
||||
SetMessages("id", indonesianMessages)
|
||||
}
|
||||
|
||||
// SetMessages sets messages for a language
|
||||
func SetMessages(language string, messages map[string]string) {
|
||||
messageStore.mu.Lock()
|
||||
defer messageStore.mu.Unlock()
|
||||
|
||||
if messageStore.messages[language] == nil {
|
||||
messageStore.messages[language] = make(map[string]string)
|
||||
}
|
||||
|
||||
for code, message := range messages {
|
||||
messageStore.messages[language][code] = message
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessage sets a single message for a language
|
||||
func SetMessage(language, code, message string) {
|
||||
messageStore.mu.Lock()
|
||||
defer messageStore.mu.Unlock()
|
||||
|
||||
if messageStore.messages[language] == nil {
|
||||
messageStore.messages[language] = make(map[string]string)
|
||||
}
|
||||
|
||||
messageStore.messages[language][code] = message
|
||||
}
|
||||
|
||||
// GetLocalizedMessage returns localized message for error code
|
||||
func GetLocalizedMessage(code, language, defaultMessage string) string {
|
||||
messageStore.mu.RLock()
|
||||
defer messageStore.mu.RUnlock()
|
||||
|
||||
// Try specific language
|
||||
if langMessages, exists := messageStore.messages[language]; exists {
|
||||
if message, exists := langMessages[code]; exists {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// Try English as fallback
|
||||
if langMessages, exists := messageStore.messages["en"]; exists {
|
||||
if message, exists := langMessages[code]; exists {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// Return default message
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
// GetSupportedLanguages returns all supported languages
|
||||
func GetSupportedLanguages() []string {
|
||||
messageStore.mu.RLock()
|
||||
defer messageStore.mu.RUnlock()
|
||||
|
||||
languages := make([]string, 0, len(messageStore.messages))
|
||||
for lang := range messageStore.messages {
|
||||
languages = append(languages, lang)
|
||||
}
|
||||
return languages
|
||||
}
|
||||
|
||||
// LoadMessagesFromJSON loads messages from JSON file
|
||||
func LoadMessagesFromJSON(language, jsonData string) error {
|
||||
var messages map[string]string
|
||||
if err := json.Unmarshal([]byte(jsonData), &messages); err != nil {
|
||||
return fmt.Errorf("failed to parse JSON messages: %w", err)
|
||||
}
|
||||
|
||||
SetMessages(language, messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatMessage formats message with parameters
|
||||
func FormatMessage(template string, params map[string]interface{}) string {
|
||||
result := template
|
||||
for key, value := range params {
|
||||
placeholder := fmt.Sprintf("{{%s}}", key)
|
||||
result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetLocalizedMessageWithParams returns localized message with parameters
|
||||
func GetLocalizedMessageWithParams(code, language, defaultMessage string, params map[string]interface{}) string {
|
||||
message := GetLocalizedMessage(code, language, defaultMessage)
|
||||
if params != nil {
|
||||
message = FormatMessage(message, params)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// MessageBuilder builds localized messages
|
||||
type MessageBuilder struct {
|
||||
language string
|
||||
code string
|
||||
params map[string]interface{}
|
||||
}
|
||||
|
||||
// NewMessageBuilder creates a new message builder.
|
||||
// Parameter 'lang' dianjurkan untuk dikirim berdasarkan context HTTP request
|
||||
// misalnya dari header Accept-Language dari klien per request.
|
||||
func NewMessageBuilder(lang string) *MessageBuilder {
|
||||
if lang == "" {
|
||||
lang = "en" // Fallback language default
|
||||
}
|
||||
return &MessageBuilder{
|
||||
language: lang,
|
||||
params: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Language sets language
|
||||
func (mb *MessageBuilder) Language(lang string) *MessageBuilder {
|
||||
mb.language = lang
|
||||
return mb
|
||||
}
|
||||
|
||||
// Code sets error code
|
||||
func (mb *MessageBuilder) Code(code string) *MessageBuilder {
|
||||
mb.code = code
|
||||
return mb
|
||||
}
|
||||
|
||||
// Param adds parameter
|
||||
func (mb *MessageBuilder) Param(key string, value interface{}) *MessageBuilder {
|
||||
mb.params[key] = value
|
||||
return mb
|
||||
}
|
||||
|
||||
// Params adds multiple parameters
|
||||
func (mb *MessageBuilder) Params(params map[string]interface{}) *MessageBuilder {
|
||||
for k, v := range params {
|
||||
mb.params[k] = v
|
||||
}
|
||||
return mb
|
||||
}
|
||||
|
||||
// Build builds the final message
|
||||
func (mb *MessageBuilder) Build(defaultMessage string) string {
|
||||
return GetLocalizedMessageWithParams(mb.code, mb.language, defaultMessage, mb.params)
|
||||
}
|
||||
|
||||
// ValidationMessages provides common validation messages
|
||||
type ValidationMessages struct {
|
||||
Required string
|
||||
Invalid string
|
||||
TooShort string
|
||||
TooLong string
|
||||
InvalidEmail string
|
||||
InvalidPhone string
|
||||
}
|
||||
|
||||
// GetValidationMessages returns validation messages for language
|
||||
func GetValidationMessages(language string) ValidationMessages {
|
||||
return ValidationMessages{
|
||||
Required: GetLocalizedMessage("VALIDATION_REQUIRED", language, "This field is required"),
|
||||
Invalid: GetLocalizedMessage("VALIDATION_INVALID", language, "Invalid value"),
|
||||
TooShort: GetLocalizedMessage("VALIDATION_TOO_SHORT", language, "Value is too short"),
|
||||
TooLong: GetLocalizedMessage("VALIDATION_TOO_LONG", language, "Value is too long"),
|
||||
InvalidEmail: GetLocalizedMessage("VALIDATION_INVALID_EMAIL", language, "Invalid email format"),
|
||||
InvalidPhone: GetLocalizedMessage("VALIDATION_INVALID_PHONE", language, "Invalid phone format"),
|
||||
}
|
||||
}
|
||||
|
||||
// AddValidationMessages adds validation messages for language
|
||||
func AddValidationMessages(language string, messages ValidationMessages) {
|
||||
SetMessage(language, "VALIDATION_REQUIRED", messages.Required)
|
||||
SetMessage(language, "VALIDATION_INVALID", messages.Invalid)
|
||||
SetMessage(language, "VALIDATION_TOO_SHORT", messages.TooShort)
|
||||
SetMessage(language, "VALIDATION_TOO_LONG", messages.TooLong)
|
||||
SetMessage(language, "VALIDATION_INVALID_EMAIL", messages.InvalidEmail)
|
||||
SetMessage(language, "VALIDATION_INVALID_PHONE", messages.InvalidPhone)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Metrics holds error metrics
|
||||
type Metrics struct {
|
||||
TotalErrors int
|
||||
ErrorsByCode map[string]int
|
||||
ErrorsByCategory map[string]int
|
||||
}
|
||||
|
||||
// ErrorMetrics represents error metrics for external use
|
||||
type ErrorMetrics struct {
|
||||
TotalErrors int `json:"total_errors"`
|
||||
ErrorsByCode map[string]int `json:"errors_by_code"`
|
||||
ErrorsByCategory map[string]int `json:"errors_by_category"`
|
||||
}
|
||||
|
||||
var (
|
||||
globalMetrics = &Metrics{
|
||||
ErrorsByCode: make(map[string]int),
|
||||
ErrorsByCategory: make(map[string]int),
|
||||
}
|
||||
metricsMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// RecordError records an error in metrics
|
||||
func RecordError(err Error) {
|
||||
metricsMutex.Lock()
|
||||
defer metricsMutex.Unlock()
|
||||
|
||||
globalMetrics.TotalErrors++
|
||||
globalMetrics.ErrorsByCode[err.Code()]++
|
||||
globalMetrics.ErrorsByCategory[err.Category()]++
|
||||
}
|
||||
|
||||
// GetMetrics returns a copy of current metrics
|
||||
func GetMetrics() *Metrics {
|
||||
metricsMutex.RLock()
|
||||
defer metricsMutex.RUnlock()
|
||||
|
||||
// Create a deep copy
|
||||
copy := &Metrics{
|
||||
TotalErrors: globalMetrics.TotalErrors,
|
||||
ErrorsByCode: make(map[string]int),
|
||||
ErrorsByCategory: make(map[string]int),
|
||||
}
|
||||
|
||||
for k, v := range globalMetrics.ErrorsByCode {
|
||||
copy.ErrorsByCode[k] = v
|
||||
}
|
||||
|
||||
for k, v := range globalMetrics.ErrorsByCategory {
|
||||
copy.ErrorsByCategory[k] = v
|
||||
}
|
||||
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"service/pkg/logger"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ErrorMiddlewareConfig configures error middleware
|
||||
type ErrorMiddlewareConfig struct {
|
||||
// SkipRoutes defines routes to skip error handling
|
||||
SkipRoutes []string
|
||||
|
||||
// CustomHandlers defines custom error handlers
|
||||
CustomHandlers map[string]ErrorHandlerFunc
|
||||
|
||||
// LogAllErrors enables logging all errors
|
||||
LogAllErrors bool
|
||||
|
||||
// SanitizeErrors enables error sanitization
|
||||
SanitizeErrors bool
|
||||
|
||||
// DefaultLanguage sets default language for error messages
|
||||
DefaultLanguage string
|
||||
|
||||
// RequestIDHeader sets header name for request ID
|
||||
RequestIDHeader string
|
||||
|
||||
// EnableMetrics enables error metrics collection
|
||||
EnableMetrics bool
|
||||
|
||||
// Timeout sets timeout for error handling
|
||||
Timeout time.Duration
|
||||
|
||||
// RecoveryEnabled enables panic recovery
|
||||
RecoveryEnabled bool
|
||||
|
||||
// CircuitBreakerEnabled enables circuit breaker
|
||||
CircuitBreakerEnabled bool
|
||||
}
|
||||
|
||||
// ErrorHandlerFunc handles errors
|
||||
type ErrorHandlerFunc func(*gin.Context, error)
|
||||
|
||||
// DefaultMiddlewareConfig returns default configuration
|
||||
func DefaultMiddlewareConfig() *ErrorMiddlewareConfig {
|
||||
return &ErrorMiddlewareConfig{
|
||||
SkipRoutes: []string{"/health", "/metrics", "/ping"},
|
||||
LogAllErrors: true,
|
||||
SanitizeErrors: true,
|
||||
DefaultLanguage: "en",
|
||||
RequestIDHeader: "X-Request-ID",
|
||||
EnableMetrics: true,
|
||||
Timeout: 30 * time.Second,
|
||||
RecoveryEnabled: true,
|
||||
CircuitBreakerEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorMiddleware creates Gin middleware for error handling
|
||||
func ErrorMiddleware(config ...*ErrorMiddlewareConfig) gin.HandlerFunc {
|
||||
var cfg *ErrorMiddlewareConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
} else {
|
||||
cfg = DefaultMiddlewareConfig()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Skip specified routes
|
||||
for _, route := range cfg.SkipRoutes {
|
||||
if c.Request.URL.Path == route {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader(cfg.RequestIDHeader)
|
||||
if requestID == "" {
|
||||
requestID = generateRequestID()
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
|
||||
// Set language
|
||||
lang := getLanguageFromContext(c)
|
||||
if lang == "" {
|
||||
lang = cfg.DefaultLanguage
|
||||
}
|
||||
c.Set("language", lang)
|
||||
|
||||
// Create context with timeout
|
||||
if cfg.Timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
// Process request
|
||||
c.Next()
|
||||
|
||||
// Handle errors
|
||||
if len(c.Errors) > 0 {
|
||||
handleErrors(c, c.Errors.Last().Err, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecoveryMiddleware creates recovery middleware
|
||||
func RecoveryMiddleware(config ...*ErrorMiddlewareConfig) gin.HandlerFunc {
|
||||
var cfg *ErrorMiddlewareConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
} else {
|
||||
cfg = DefaultMiddlewareConfig()
|
||||
}
|
||||
|
||||
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
var err error
|
||||
|
||||
switch x := recovered.(type) {
|
||||
case string:
|
||||
err = NewWithCode(ErrCodeInternalError, x)
|
||||
case error:
|
||||
err = FromError(x)
|
||||
default:
|
||||
err = NewWithCode(ErrCodeUnexpectedError,
|
||||
fmt.Sprintf("Unknown panic: %v", x))
|
||||
}
|
||||
|
||||
handleErrors(c, err, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
// CombinedMiddleware creates combined error and recovery middleware
|
||||
func CombinedMiddleware(config ...*ErrorMiddlewareConfig) gin.HandlerFunc {
|
||||
cfg := DefaultMiddlewareConfig()
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Skip specified routes
|
||||
for _, route := range cfg.SkipRoutes {
|
||||
if c.Request.URL.Path == route {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate request ID if not present
|
||||
requestID := c.GetHeader(cfg.RequestIDHeader)
|
||||
if requestID == "" {
|
||||
requestID = generateRequestID()
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
|
||||
// Set language
|
||||
lang := getLanguageFromContext(c)
|
||||
if lang == "" {
|
||||
lang = cfg.DefaultLanguage
|
||||
}
|
||||
c.Set("language", lang)
|
||||
|
||||
// Create context with timeout
|
||||
if cfg.Timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
|
||||
defer cancel()
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
// Process request with recovery
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
var err error
|
||||
|
||||
switch x := r.(type) {
|
||||
case string:
|
||||
err = NewWithCode(ErrCodeInternalError, x)
|
||||
case error:
|
||||
err = FromError(x)
|
||||
default:
|
||||
err = NewWithCode(ErrCodeUnexpectedError,
|
||||
fmt.Sprintf("Unknown panic: %v", x))
|
||||
}
|
||||
|
||||
handleErrors(c, err, cfg)
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
|
||||
// Handle errors
|
||||
if len(c.Errors) > 0 {
|
||||
handleErrors(c, c.Errors.Last().Err, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleErrors handles errors with configuration
|
||||
func handleErrors(c *gin.Context, err error, cfg *ErrorMiddlewareConfig) {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, "Unknown error")
|
||||
}
|
||||
|
||||
// Apply custom handlers
|
||||
if handler, exists := cfg.CustomHandlers[appErr.Code()]; exists {
|
||||
handler(c, appErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply registered handlers
|
||||
appErr = ApplyHandlers(appErr)
|
||||
|
||||
// Sanitize error if needed
|
||||
if cfg.SanitizeErrors {
|
||||
appErr = sanitizeError(appErr).(Error)
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
if cfg.EnableMetrics {
|
||||
RecordError(appErr)
|
||||
}
|
||||
|
||||
// Log error
|
||||
if cfg.LogAllErrors {
|
||||
log := logger.Default()
|
||||
log.Error("HTTP error occurred",
|
||||
logger.String("error_code", appErr.Code()),
|
||||
logger.String("category", appErr.Category()),
|
||||
logger.Int("http_status", appErr.HTTPStatus()),
|
||||
logger.Any("metadata", appErr.Metadata()),
|
||||
logger.String("method", c.Request.Method),
|
||||
logger.String("path", c.Request.URL.Path),
|
||||
logger.String("request_id", c.GetString("request_id")),
|
||||
)
|
||||
}
|
||||
|
||||
// Create error response
|
||||
response := createErrorResponse(c, appErr, cfg)
|
||||
|
||||
// Send response
|
||||
c.JSON(appErr.HTTPStatus(), response)
|
||||
}
|
||||
|
||||
// createErrorResponse creates error response
|
||||
func createErrorResponse(c *gin.Context, err Error, cfg *ErrorMiddlewareConfig) gin.H {
|
||||
lang := c.GetString("language")
|
||||
requestID := c.GetString("request_id")
|
||||
|
||||
response := gin.H{
|
||||
"error": gin.H{
|
||||
"code": err.Code(),
|
||||
"message": err.GetLocalizedMessage(lang),
|
||||
"details": err.Metadata(),
|
||||
"request_id": requestID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"retryable": IsRetryable(err.Code()),
|
||||
},
|
||||
"meta": gin.H{
|
||||
"http_status": err.HTTPStatus(),
|
||||
"category": err.Category(),
|
||||
},
|
||||
}
|
||||
|
||||
// Add stack trace in debug mode
|
||||
if gin.Mode() == gin.DebugMode {
|
||||
response["error"].(gin.H)["stack_trace"] = err.Metadata()["stack_trace"]
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// CustomErrorHandler creates custom error handler
|
||||
func CustomErrorHandler(code string, handler ErrorHandlerFunc) ErrorHandlerFunc {
|
||||
return func(c *gin.Context, err error) {
|
||||
appErr := FromError(err)
|
||||
if appErr != nil && appErr.Code() == code {
|
||||
handler(c, appErr)
|
||||
return
|
||||
}
|
||||
// Fallback to default handling
|
||||
HandleHTTPError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationHandler creates validation error handler
|
||||
func ValidationHandler(c *gin.Context, err error) {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeValidationFailed, err.Error())
|
||||
}
|
||||
|
||||
// Enhance validation errors with field details
|
||||
if appErr.Category() == CategoryValidation {
|
||||
if details, ok := appErr.Metadata()["validation_errors"]; ok {
|
||||
response := gin.H{
|
||||
"error": gin.H{
|
||||
"code": appErr.Code(),
|
||||
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
|
||||
"fields": details,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
HandleHTTPError(c, appErr)
|
||||
}
|
||||
|
||||
// TimeoutHandler creates timeout error handler
|
||||
func TimeoutHandler(c *gin.Context, err error) {
|
||||
appErr := NewWithCode(ErrCodeTimeout, "Request timeout").
|
||||
WithMetadata("timeout", c.GetHeader("X-Timeout")).
|
||||
WithMetadata("endpoint", c.Request.URL.Path)
|
||||
|
||||
HandleHTTPError(c, appErr)
|
||||
}
|
||||
|
||||
// RateLimitHandler creates rate limit error handler
|
||||
func RateLimitHandler(c *gin.Context, err error) {
|
||||
appErr := NewWithCode(ErrCodeRateLimitExceeded, "Rate limit exceeded").
|
||||
WithMetadata("limit", c.GetHeader("X-Rate-Limit-Limit")).
|
||||
WithMetadata("remaining", c.GetHeader("X-Rate-Limit-Remaining")).
|
||||
WithMetadata("reset", c.GetHeader("X-Rate-Limit-Reset"))
|
||||
|
||||
HandleHTTPError(c, appErr)
|
||||
}
|
||||
|
||||
// NotFoundHandler creates 404 error handler
|
||||
func NotFoundHandler(c *gin.Context) {
|
||||
err := NewWithCode(ErrCodeNotFound,
|
||||
fmt.Sprintf("Route %s %s not found", c.Request.Method, c.Request.URL.Path)).
|
||||
WithMetadata("method", c.Request.Method).
|
||||
WithMetadata("path", c.Request.URL.Path).
|
||||
WithMetadata("query", c.Request.URL.RawQuery)
|
||||
|
||||
HandleHTTPError(c, err)
|
||||
}
|
||||
|
||||
// MethodNotAllowedHandler creates 405 error handler
|
||||
func MethodNotAllowedHandler(c *gin.Context) {
|
||||
err := NewWithCode(ErrCodeInvalidInput,
|
||||
fmt.Sprintf("Method %s not allowed for route %s", c.Request.Method, c.Request.URL.Path)).
|
||||
WithMetadata("method", c.Request.Method).
|
||||
WithMetadata("path", c.Request.URL.Path).
|
||||
WithMetadata("allowed_methods", c.GetHeader("Allow"))
|
||||
|
||||
HandleHTTPError(c, err)
|
||||
}
|
||||
|
||||
// GRPCErrorInterceptor creates gRPC error interceptor
|
||||
func GRPCErrorInterceptor(config ...*ErrorMiddlewareConfig) grpc.UnaryServerInterceptor {
|
||||
cfg := DefaultMiddlewareConfig()
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
return nil, handleGRPCError(err, cfg)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleGRPCError handles gRPC error with configuration
|
||||
func handleGRPCError(err error, cfg *ErrorMiddlewareConfig) error {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, "Internal server error")
|
||||
}
|
||||
|
||||
// Apply registered handlers
|
||||
appErr = ApplyHandlers(appErr)
|
||||
|
||||
// Sanitize error if needed
|
||||
if cfg.SanitizeErrors {
|
||||
appErr = sanitizeError(appErr).(Error)
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
if cfg.EnableMetrics {
|
||||
RecordError(appErr)
|
||||
}
|
||||
|
||||
// Log error
|
||||
if cfg.LogAllErrors {
|
||||
log := logger.Default()
|
||||
log.Error("gRPC error occurred",
|
||||
logger.String("error_code", appErr.Code()),
|
||||
logger.String("category", appErr.Category()),
|
||||
logger.String("grpc_code", appErr.GRPCCode().String()),
|
||||
logger.Any("metadata", appErr.Metadata()),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to gRPC status
|
||||
return status.Error(appErr.GRPCCode(), appErr.GetLocalizedMessage(cfg.DefaultLanguage))
|
||||
}
|
||||
|
||||
// GetErrorMetrics returns current error metrics
|
||||
func GetErrorMetrics() ErrorMetrics {
|
||||
m := GetMetrics()
|
||||
return ErrorMetrics{
|
||||
TotalErrors: m.TotalErrors,
|
||||
ErrorsByCode: m.ErrorsByCode,
|
||||
ErrorsByCategory: m.ErrorsByCategory,
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsHandler creates metrics endpoint handler
|
||||
func MetricsHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
metrics := GetErrorMetrics()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error_metrics": metrics,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HealthHandler creates health endpoint handler
|
||||
func HealthHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"service": "error-handler",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"service/pkg/logger"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig configures retry behavior
|
||||
type RetryConfig struct {
|
||||
MaxAttempts int
|
||||
Delay time.Duration
|
||||
MaxDelay time.Duration
|
||||
Backoff BackoffStrategy
|
||||
RetryIf func(Error) bool
|
||||
OnRetry func(attempt int, err Error)
|
||||
}
|
||||
|
||||
// BackoffStrategy defines backoff strategy
|
||||
type BackoffStrategy int
|
||||
|
||||
const (
|
||||
LinearBackoff BackoffStrategy = iota
|
||||
ExponentialBackoff
|
||||
ExponentialBackoffWithJitter
|
||||
FixedBackoff
|
||||
)
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() *RetryConfig {
|
||||
return &RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
Delay: time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
Backoff: ExponentialBackoff,
|
||||
RetryIf: DefaultRetryCondition,
|
||||
OnRetry: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRetryCondition determines if error is retryable
|
||||
func DefaultRetryCondition(err Error) bool {
|
||||
return IsRetryable(err.Code())
|
||||
}
|
||||
|
||||
// Retry executes function with retry logic
|
||||
func Retry(fn func() (interface{}, error), config *RetryConfig) (interface{}, error) {
|
||||
if config == nil {
|
||||
config = DefaultRetryConfig()
|
||||
}
|
||||
|
||||
var lastErr Error
|
||||
log := logger.Default()
|
||||
|
||||
for attempt := 0; attempt < config.MaxAttempts; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, err.Error())
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if config.RetryIf != nil && !config.RetryIf(appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
lastErr = appErr
|
||||
|
||||
// Don't wait after last attempt
|
||||
if attempt < config.MaxAttempts-1 {
|
||||
delay := calculateDelay(attempt, config)
|
||||
|
||||
if config.OnRetry != nil {
|
||||
config.OnRetry(attempt+1, appErr)
|
||||
}
|
||||
|
||||
log.Warn(appErr.Error(),
|
||||
logger.Int("attempt", attempt+1),
|
||||
logger.Int("max_attempts", config.MaxAttempts),
|
||||
logger.String("retry_delay", delay.String()),
|
||||
logger.Bool("will_retry", attempt < config.MaxAttempts-1),
|
||||
)
|
||||
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// RetryWithContext executes function with retry logic and context
|
||||
func RetryWithContext(ctx context.Context, fn func() (interface{}, error), config *RetryConfig) (interface{}, error) {
|
||||
if config == nil {
|
||||
config = DefaultRetryConfig()
|
||||
}
|
||||
|
||||
var lastErr Error
|
||||
log := logger.Default()
|
||||
|
||||
for attempt := 0; attempt < config.MaxAttempts; attempt++ {
|
||||
// Check context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, Wrap(ctx.Err(), ErrCodeTimeout, "Context cancelled during retry")
|
||||
default:
|
||||
}
|
||||
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
appErr = NewWithCode(ErrCodeInternalError, err.Error())
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if config.RetryIf != nil && !config.RetryIf(appErr) {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
lastErr = appErr
|
||||
|
||||
// Don't wait after last attempt
|
||||
if attempt < config.MaxAttempts-1 {
|
||||
delay := calculateDelay(attempt, config)
|
||||
|
||||
if config.OnRetry != nil {
|
||||
config.OnRetry(attempt+1, appErr)
|
||||
}
|
||||
|
||||
log.Warn(appErr.Error(),
|
||||
logger.Int("attempt", attempt+1),
|
||||
logger.Int("max_attempts", config.MaxAttempts),
|
||||
logger.String("retry_delay", delay.String()),
|
||||
logger.Bool("will_retry", attempt < config.MaxAttempts-1),
|
||||
)
|
||||
|
||||
// Wait with context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, Wrap(ctx.Err(), ErrCodeTimeout, "Context cancelled during retry delay")
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// calculateDelay calculates delay based on backoff strategy
|
||||
func calculateDelay(attempt int, config *RetryConfig) time.Duration {
|
||||
var delay time.Duration
|
||||
|
||||
switch config.Backoff {
|
||||
case LinearBackoff:
|
||||
delay = time.Duration(attempt+1) * config.Delay
|
||||
case ExponentialBackoff:
|
||||
delay = config.Delay * time.Duration(math.Pow(2, float64(attempt)))
|
||||
case ExponentialBackoffWithJitter:
|
||||
delay = config.Delay * time.Duration(math.Pow(2, float64(attempt)))
|
||||
// Add jitter
|
||||
jitter := time.Duration(rand.Float64() * float64(delay) * 0.1)
|
||||
delay += jitter
|
||||
case FixedBackoff:
|
||||
delay = config.Delay
|
||||
default:
|
||||
delay = config.Delay
|
||||
}
|
||||
|
||||
// Apply max delay limit
|
||||
if delay > config.MaxDelay {
|
||||
delay = config.MaxDelay
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
// CircuitBreaker implements circuit breaker pattern
|
||||
type CircuitBreaker struct {
|
||||
name string
|
||||
maxFailures int
|
||||
resetTimeout time.Duration
|
||||
state CircuitState
|
||||
failures int
|
||||
lastFailTime time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// CircuitState represents circuit breaker state
|
||||
type CircuitState int
|
||||
|
||||
const (
|
||||
CircuitClosed CircuitState = iota
|
||||
CircuitOpen
|
||||
CircuitHalfOpen
|
||||
)
|
||||
|
||||
// NewCircuitBreaker creates new circuit breaker
|
||||
func NewCircuitBreaker(name string, maxFailures int, resetTimeout time.Duration) *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
name: name,
|
||||
maxFailures: maxFailures,
|
||||
resetTimeout: resetTimeout,
|
||||
state: CircuitClosed,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes function with circuit breaker protection
|
||||
func (cb *CircuitBreaker) Execute(fn func() (interface{}, error)) (interface{}, error) {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
log := logger.Default()
|
||||
|
||||
// Check if circuit is open
|
||||
if cb.state == CircuitOpen {
|
||||
if time.Since(cb.lastFailTime) > cb.resetTimeout {
|
||||
cb.state = CircuitHalfOpen
|
||||
log.Info("Circuit breaker transitioning to half-open", logger.String("circuit", cb.name))
|
||||
} else {
|
||||
return nil, NewWithCode(ErrCodeServiceUnavailable,
|
||||
fmt.Sprintf("Circuit breaker '%s' is open", cb.name))
|
||||
}
|
||||
}
|
||||
|
||||
// Execute function
|
||||
result, err := fn()
|
||||
if err != nil {
|
||||
cb.onFailure()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cb.onSuccess()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// onSuccess handles successful execution
|
||||
func (cb *CircuitBreaker) onSuccess() {
|
||||
cb.failures = 0
|
||||
if cb.state == CircuitHalfOpen {
|
||||
cb.state = CircuitClosed
|
||||
log := logger.Default()
|
||||
log.Info("Circuit breaker closed", logger.String("circuit", cb.name))
|
||||
}
|
||||
}
|
||||
|
||||
// onFailure handles failed execution
|
||||
func (cb *CircuitBreaker) onFailure() {
|
||||
cb.failures++
|
||||
cb.lastFailTime = time.Now()
|
||||
|
||||
if cb.failures >= cb.maxFailures {
|
||||
cb.state = CircuitOpen
|
||||
log := logger.Default()
|
||||
log.Warn("Circuit breaker opened",
|
||||
logger.String("circuit", cb.name),
|
||||
logger.Int("failures", cb.failures),
|
||||
logger.Int("max_failures", cb.maxFailures),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns current circuit breaker state
|
||||
func (cb *CircuitBreaker) GetState() CircuitState {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
return cb.state
|
||||
}
|
||||
|
||||
// GetFailures returns current failure count
|
||||
func (cb *CircuitBreaker) GetFailures() int {
|
||||
cb.mu.RLock()
|
||||
defer cb.mu.RUnlock()
|
||||
return cb.failures
|
||||
}
|
||||
|
||||
// Reset resets circuit breaker
|
||||
func (cb *CircuitBreaker) Reset() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.state = CircuitClosed
|
||||
cb.failures = 0
|
||||
cb.lastFailTime = time.Time{}
|
||||
|
||||
log := logger.Default()
|
||||
log.Info("Circuit breaker reset", logger.String("circuit", cb.name))
|
||||
}
|
||||
|
||||
// RecoveryManager manages error recovery strategies
|
||||
type RecoveryManager struct {
|
||||
circuitBreakers map[string]*CircuitBreaker
|
||||
retryConfigs map[string]*RetryConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRecoveryManager creates new recovery manager
|
||||
func NewRecoveryManager() *RecoveryManager {
|
||||
return &RecoveryManager{
|
||||
circuitBreakers: make(map[string]*CircuitBreaker),
|
||||
retryConfigs: make(map[string]*RetryConfig),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterCircuitBreaker registers circuit breaker
|
||||
func (rm *RecoveryManager) RegisterCircuitBreaker(name string, maxFailures int, resetTimeout time.Duration) {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
rm.circuitBreakers[name] = NewCircuitBreaker(name, maxFailures, resetTimeout)
|
||||
}
|
||||
|
||||
// RegisterRetryConfig registers retry configuration
|
||||
func (rm *RecoveryManager) RegisterRetryConfig(name string, config *RetryConfig) {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
rm.retryConfigs[name] = config
|
||||
}
|
||||
|
||||
// ExecuteWithRecovery executes function with recovery strategies
|
||||
func (rm *RecoveryManager) ExecuteWithRecovery(operationName string, fn func() (interface{}, error)) (interface{}, error) {
|
||||
rm.mu.RLock()
|
||||
cb, hasCB := rm.circuitBreakers[operationName]
|
||||
config, hasConfig := rm.retryConfigs[operationName]
|
||||
rm.mu.RUnlock()
|
||||
|
||||
// Execute with circuit breaker if available
|
||||
if hasCB {
|
||||
return cb.Execute(func() (interface{}, error) {
|
||||
// Execute with retry if available
|
||||
if hasConfig {
|
||||
return Retry(fn, config)
|
||||
}
|
||||
return fn()
|
||||
})
|
||||
}
|
||||
|
||||
// Execute with retry only
|
||||
if hasConfig {
|
||||
return Retry(fn, config)
|
||||
}
|
||||
|
||||
// Execute normally
|
||||
return fn()
|
||||
}
|
||||
|
||||
// GetCircuitBreaker returns circuit breaker by name
|
||||
func (rm *RecoveryManager) GetCircuitBreaker(name string) (*CircuitBreaker, bool) {
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
cb, exists := rm.circuitBreakers[name]
|
||||
return cb, exists
|
||||
}
|
||||
|
||||
// GetRetryConfig returns retry config by name
|
||||
func (rm *RecoveryManager) GetRetryConfig(name string) (*RetryConfig, bool) {
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
config, exists := rm.retryConfigs[name]
|
||||
return config, exists
|
||||
}
|
||||
|
||||
// Global recovery manager
|
||||
var globalRecoveryManager = NewRecoveryManager()
|
||||
|
||||
// GetRecoveryManager returns global recovery manager
|
||||
func GetRecoveryManager() *RecoveryManager {
|
||||
return globalRecoveryManager
|
||||
}
|
||||
|
||||
// ExecuteWithGlobalRecovery executes with global recovery manager
|
||||
func ExecuteWithGlobalRecovery(operationName string, fn func() (interface{}, error)) (interface{}, error) {
|
||||
return globalRecoveryManager.ExecuteWithRecovery(operationName, fn)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
||||
// ErrorType represents a custom error type
|
||||
type ErrorType interface {
|
||||
Name() string
|
||||
New(message string, metadata Metadata) Error
|
||||
FromError(err error) Error
|
||||
Validate(metadata Metadata) error
|
||||
}
|
||||
|
||||
// ErrorRegistry manages custom error types
|
||||
type ErrorRegistry struct {
|
||||
types map[string]ErrorType
|
||||
handlers map[string]func(Error) Error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
registry = &ErrorRegistry{
|
||||
types: make(map[string]ErrorType),
|
||||
handlers: make(map[string]func(Error) Error),
|
||||
}
|
||||
)
|
||||
|
||||
// RegisterErrorType registers a custom error type
|
||||
func RegisterErrorType(name string, errorType ErrorType) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
registry.types[name] = errorType
|
||||
}
|
||||
|
||||
// GetErrorType returns error type by name
|
||||
func GetErrorType(name string) (ErrorType, bool) {
|
||||
registry.mu.RLock()
|
||||
defer registry.mu.RUnlock()
|
||||
|
||||
errorType, exists := registry.types[name]
|
||||
return errorType, exists
|
||||
}
|
||||
|
||||
// RegisterErrorHandler registers custom error handler
|
||||
func RegisterErrorHandler(code string, handler func(Error) Error) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
registry.handlers[code] = handler
|
||||
}
|
||||
|
||||
// GetErrorHandler returns error handler for code
|
||||
func GetErrorHandler(code string) (func(Error) Error, bool) {
|
||||
registry.mu.RLock()
|
||||
defer registry.mu.RUnlock()
|
||||
|
||||
handler, exists := registry.handlers[code]
|
||||
return handler, exists
|
||||
}
|
||||
|
||||
// ApplyHandlers applies registered handlers to error
|
||||
func ApplyHandlers(err Error) Error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply specific handler for error code
|
||||
if handler, exists := GetErrorHandler(err.Code()); exists {
|
||||
err = handler(err)
|
||||
}
|
||||
|
||||
// Apply category handlers
|
||||
categoryHandlers := GetCategoryHandlers(err.Category())
|
||||
for _, handler := range categoryHandlers {
|
||||
err = handler(err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CategoryHandler handles errors by category
|
||||
type CategoryHandler struct {
|
||||
Category string
|
||||
Handler func(Error) Error
|
||||
}
|
||||
|
||||
var (
|
||||
categoryHandlers = make(map[string][]func(Error) Error)
|
||||
categoryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// RegisterCategoryHandler registers handler for error category
|
||||
func RegisterCategoryHandler(category string, handler func(Error) Error) {
|
||||
categoryMu.Lock()
|
||||
defer categoryMu.Unlock()
|
||||
|
||||
if categoryHandlers[category] == nil {
|
||||
categoryHandlers[category] = make([]func(Error) Error, 0)
|
||||
}
|
||||
categoryHandlers[category] = append(categoryHandlers[category], handler)
|
||||
}
|
||||
|
||||
// GetCategoryHandlers returns handlers for category
|
||||
func GetCategoryHandlers(category string) []func(Error) Error {
|
||||
categoryMu.RLock()
|
||||
defer categoryMu.RUnlock()
|
||||
|
||||
handlers := make([]func(Error) Error, len(categoryHandlers[category]))
|
||||
copy(handlers, categoryHandlers[category])
|
||||
return handlers
|
||||
}
|
||||
|
||||
// BaseErrorType provides base implementation for custom error types
|
||||
type BaseErrorType struct {
|
||||
name string
|
||||
defaultCode string
|
||||
category string
|
||||
httpStatus int
|
||||
grpcCode int
|
||||
validator func(Metadata) error
|
||||
}
|
||||
|
||||
// NewBaseErrorType creates new base error type
|
||||
func NewBaseErrorType(name, defaultCode, category string, httpStatus int, grpcCode int) *BaseErrorType {
|
||||
return &BaseErrorType{
|
||||
name: name,
|
||||
defaultCode: defaultCode,
|
||||
category: category,
|
||||
httpStatus: httpStatus,
|
||||
grpcCode: grpcCode,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns error type name
|
||||
func (bet *BaseErrorType) Name() string {
|
||||
return bet.name
|
||||
}
|
||||
|
||||
// New creates new error of this type
|
||||
func (bet *BaseErrorType) New(message string, metadata Metadata) Error {
|
||||
code := bet.defaultCode
|
||||
if c, exists := metadata["code"].(string); exists {
|
||||
code = c
|
||||
}
|
||||
|
||||
return &AppError{
|
||||
code: code,
|
||||
message: message,
|
||||
category: bet.category,
|
||||
httpStatus: bet.httpStatus,
|
||||
grpcCode: codes.Code(bet.grpcCode),
|
||||
metadata: metadata,
|
||||
timestamp: time.Now(),
|
||||
stackTrace: captureStackTrace(),
|
||||
}
|
||||
}
|
||||
|
||||
// FromError converts existing error to this type
|
||||
func (bet *BaseErrorType) FromError(err error) Error {
|
||||
appErr := FromError(err)
|
||||
if appErr == nil {
|
||||
return bet.New(err.Error(), make(Metadata))
|
||||
}
|
||||
|
||||
return bet.New(appErr.Error(), appErr.Metadata())
|
||||
}
|
||||
|
||||
// Validate validates metadata
|
||||
func (bet *BaseErrorType) Validate(metadata Metadata) error {
|
||||
if bet.validator != nil {
|
||||
return bet.validator(metadata)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetValidator sets validator function
|
||||
func (bet *BaseErrorType) SetValidator(validator func(Metadata) error) {
|
||||
bet.validator = validator
|
||||
}
|
||||
|
||||
// BusinessErrorType represents business logic errors
|
||||
type BusinessErrorType struct {
|
||||
*BaseErrorType
|
||||
ruleName string
|
||||
}
|
||||
|
||||
// NewBusinessErrorType creates new business error type
|
||||
func NewBusinessErrorType(name, ruleName string) *BusinessErrorType {
|
||||
return &BusinessErrorType{
|
||||
BaseErrorType: NewBaseErrorType(name, ErrCodeBusinessRule, CategoryBusiness, 400, int(codes.FailedPrecondition)),
|
||||
ruleName: ruleName,
|
||||
}
|
||||
}
|
||||
|
||||
// RuleName returns business rule name
|
||||
func (bet *BusinessErrorType) RuleName() string {
|
||||
return bet.ruleName
|
||||
}
|
||||
|
||||
// ValidationErrorType represents validation errors
|
||||
type ValidationErrorType struct {
|
||||
*BaseErrorType
|
||||
field string
|
||||
rule string
|
||||
}
|
||||
|
||||
// NewValidationErrorType creates new validation error type
|
||||
func NewValidationErrorType(name, field, rule string) *ValidationErrorType {
|
||||
return &ValidationErrorType{
|
||||
BaseErrorType: NewBaseErrorType(name, ErrCodeValidationFailed, CategoryValidation, 400, int(codes.InvalidArgument)),
|
||||
field: field,
|
||||
rule: rule,
|
||||
}
|
||||
}
|
||||
|
||||
// Field returns field name
|
||||
func (vet *ValidationErrorType) Field() string {
|
||||
return vet.field
|
||||
}
|
||||
|
||||
// Rule returns validation rule
|
||||
func (vet *ValidationErrorType) Rule() string {
|
||||
return vet.rule
|
||||
}
|
||||
|
||||
// ExternalErrorType represents external service errors
|
||||
type ExternalErrorType struct {
|
||||
*BaseErrorType
|
||||
serviceName string
|
||||
retryable bool
|
||||
}
|
||||
|
||||
// NewExternalErrorType creates new external error type
|
||||
func NewExternalErrorType(name, serviceName string, retryable bool) *ExternalErrorType {
|
||||
return &ExternalErrorType{
|
||||
BaseErrorType: NewBaseErrorType(name, ErrCodeExternalError, CategoryExternal, 502, int(codes.Unavailable)),
|
||||
serviceName: serviceName,
|
||||
retryable: retryable,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceName returns external service name
|
||||
func (eet *ExternalErrorType) ServiceName() string {
|
||||
return eet.serviceName
|
||||
}
|
||||
|
||||
// Retryable returns if error is retryable
|
||||
func (eet *ExternalErrorType) Retryable() bool {
|
||||
return eet.retryable
|
||||
}
|
||||
|
||||
// ErrorFactory creates errors from configuration
|
||||
type ErrorFactory struct {
|
||||
config map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewErrorFactory creates new error factory
|
||||
func NewErrorFactory() *ErrorFactory {
|
||||
return &ErrorFactory{
|
||||
config: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Configure configures error factory
|
||||
func (ef *ErrorFactory) Configure(config map[string]interface{}) {
|
||||
ef.mu.Lock()
|
||||
defer ef.mu.Unlock()
|
||||
ef.config = config
|
||||
}
|
||||
|
||||
// CreateError creates error from configuration
|
||||
func (ef *ErrorFactory) CreateError(errorName string, message string, metadata Metadata) Error {
|
||||
ef.mu.RLock()
|
||||
defer ef.mu.RUnlock()
|
||||
|
||||
// Try to get error type from configuration
|
||||
if errorTypeConfig, exists := ef.config[errorName]; exists {
|
||||
if configMap, ok := errorTypeConfig.(map[string]interface{}); ok {
|
||||
name, _ := configMap["name"].(string)
|
||||
if errorType, exists := GetErrorType(name); exists {
|
||||
return errorType.New(message, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to creating standard error
|
||||
return NewWithMetadata(ErrCodeInternalError, message, metadata)
|
||||
}
|
||||
|
||||
// GetRegisteredTypes returns all registered error types
|
||||
func GetRegisteredTypes() map[string]ErrorType {
|
||||
registry.mu.RLock()
|
||||
defer registry.mu.RUnlock()
|
||||
|
||||
types := make(map[string]ErrorType)
|
||||
for k, v := range registry.types {
|
||||
types[k] = v
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// GetRegisteredHandlers returns all registered handlers
|
||||
func GetRegisteredHandlers() map[string]func(Error) Error {
|
||||
registry.mu.RLock()
|
||||
defer registry.mu.RUnlock()
|
||||
|
||||
handlers := make(map[string]func(Error) Error)
|
||||
for k, v := range registry.handlers {
|
||||
handlers[k] = v
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
// ValidateErrorType validates error type configuration
|
||||
func ValidateErrorType(errorType ErrorType) error {
|
||||
if errorType.Name() == "" {
|
||||
return fmt.Errorf("error type name cannot be empty")
|
||||
}
|
||||
|
||||
// Test error creation
|
||||
testErr := errorType.New("test", make(Metadata))
|
||||
if testErr == nil {
|
||||
return fmt.Errorf("error type failed to create error")
|
||||
}
|
||||
|
||||
if testErr.Code() == "" {
|
||||
return fmt.Errorf("error type must produce error with code")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidationError represents a validation error
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Value interface{} `json:"value"`
|
||||
Rule string `json:"rule"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ValidationErrors represents multiple validation errors
|
||||
type ValidationErrors []ValidationError
|
||||
|
||||
// Error implements error interface
|
||||
func (ve ValidationErrors) Error() string {
|
||||
if len(ve) == 0 {
|
||||
return "validation failed"
|
||||
}
|
||||
|
||||
if len(ve) == 1 {
|
||||
return fmt.Sprintf("validation failed: %s", ve[0].Message)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("validation failed: %d errors", len(ve))
|
||||
}
|
||||
|
||||
// ToMap converts validation errors to map
|
||||
func (ve ValidationErrors) ToMap() map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
for _, err := range ve {
|
||||
result[err.Field] = map[string]interface{}{
|
||||
"value": err.Value,
|
||||
"rule": err.Rule,
|
||||
"message": err.Message,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Validator provides validation functionality
|
||||
type Validator struct {
|
||||
rules map[string]ValidationRule
|
||||
}
|
||||
|
||||
// ValidationRule represents a validation rule
|
||||
type ValidationRule struct {
|
||||
Name string
|
||||
Validator func(interface{}) error
|
||||
Message string
|
||||
}
|
||||
|
||||
// NewValidator creates new validator
|
||||
func NewValidator() *Validator {
|
||||
return &Validator{
|
||||
rules: make(map[string]ValidationRule),
|
||||
}
|
||||
}
|
||||
|
||||
// AddRule adds validation rule
|
||||
func (v *Validator) AddRule(name string, validator func(interface{}) error, message string) {
|
||||
v.rules[name] = ValidationRule{
|
||||
Name: name,
|
||||
Validator: validator,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates value against rules
|
||||
func (v *Validator) Validate(value interface{}, rules []string) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
|
||||
for _, ruleName := range rules {
|
||||
if rule, exists := v.rules[ruleName]; exists {
|
||||
if err := rule.Validator(value); err != nil {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: "",
|
||||
Value: value,
|
||||
Rule: ruleName,
|
||||
Message: rule.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// ValidateField validates a field with rules
|
||||
func (v *Validator) ValidateField(field string, value interface{}, rules []string) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
|
||||
for _, ruleName := range rules {
|
||||
if rule, exists := v.rules[ruleName]; exists {
|
||||
if err := rule.Validator(value); err != nil {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: field,
|
||||
Value: value,
|
||||
Rule: ruleName,
|
||||
Message: fmt.Sprintf(rule.Message, field),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// ValidateStruct validates struct fields
|
||||
func (v *Validator) ValidateStruct(obj interface{}) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
|
||||
val := reflect.ValueOf(obj)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return errors
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
|
||||
// Get validation tags
|
||||
tag := field.Tag.Get("validate")
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := strings.Split(tag, ",")
|
||||
fieldErrors := v.ValidateField(field.Name, fieldValue.Interface(), rules)
|
||||
errors = append(errors, fieldErrors...)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// Common validation rules
|
||||
var (
|
||||
// Required rule
|
||||
Required = ValidationRule{
|
||||
Name: "required",
|
||||
Validator: func(value interface{}) error {
|
||||
if value == nil || value == "" {
|
||||
return fmt.Errorf("value is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: "%s is required",
|
||||
}
|
||||
|
||||
// Email rule
|
||||
Email = ValidationRule{
|
||||
Name: "email",
|
||||
Validator: func(value interface{}) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("value must be string")
|
||||
}
|
||||
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
if !emailRegex.MatchString(str) {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: "%s must be a valid email",
|
||||
}
|
||||
|
||||
// Min length rule
|
||||
MinLength = func(min int) ValidationRule {
|
||||
return ValidationRule{
|
||||
Name: fmt.Sprintf("min_%d", min),
|
||||
Validator: func(value interface{}) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("value must be string")
|
||||
}
|
||||
|
||||
if len(str) < min {
|
||||
return fmt.Errorf("value must be at least %d characters", min)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: fmt.Sprintf("%%s must be at least %d characters", min),
|
||||
}
|
||||
}
|
||||
|
||||
// Max length rule
|
||||
MaxLength = func(max int) ValidationRule {
|
||||
return ValidationRule{
|
||||
Name: fmt.Sprintf("max_%d", max),
|
||||
Validator: func(value interface{}) error {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("value must be string")
|
||||
}
|
||||
|
||||
if len(str) > max {
|
||||
return fmt.Errorf("value must be at most %d characters", max)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: fmt.Sprintf("%%s must be at most %d characters", max),
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric rule
|
||||
Numeric = ValidationRule{
|
||||
Name: "numeric",
|
||||
Validator: func(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case int, int8, int16, int32, int64:
|
||||
return nil
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return nil
|
||||
case float32, float64:
|
||||
return nil
|
||||
case string:
|
||||
if _, err := strconv.ParseFloat(v, 64); err != nil {
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
},
|
||||
Message: "%s must be numeric",
|
||||
}
|
||||
|
||||
// Positive rule
|
||||
Positive = ValidationRule{
|
||||
Name: "positive",
|
||||
Validator: func(value interface{}) error {
|
||||
var num float64
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
num = float64(v)
|
||||
case int64:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
num = parsed
|
||||
default:
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
|
||||
if num <= 0 {
|
||||
return fmt.Errorf("value must be positive")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: "%s must be positive",
|
||||
}
|
||||
|
||||
// Range rule
|
||||
Range = func(min, max float64) ValidationRule {
|
||||
return ValidationRule{
|
||||
Name: fmt.Sprintf("range_%.1f_%.1f", min, max),
|
||||
Validator: func(value interface{}) error {
|
||||
var num float64
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
num = float64(v)
|
||||
case int64:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
num = parsed
|
||||
default:
|
||||
return fmt.Errorf("value must be numeric")
|
||||
}
|
||||
|
||||
if num < min || num > max {
|
||||
return fmt.Errorf("value must be between %.1f and %.1f", min, max)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Message: fmt.Sprintf("%%s must be between %.1f and %.1f", min, max),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// DefaultValidator returns validator with common rules
|
||||
func DefaultValidator() *Validator {
|
||||
v := NewValidator()
|
||||
v.AddRule("required", Required.Validator, Required.Message)
|
||||
v.AddRule("email", Email.Validator, Email.Message)
|
||||
v.AddRule("numeric", Numeric.Validator, Numeric.Message)
|
||||
v.AddRule("positive", Positive.Validator, Positive.Message)
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateStruct validates struct using default validator
|
||||
func ValidateStruct(obj interface{}) ValidationErrors {
|
||||
v := DefaultValidator()
|
||||
return v.ValidateStruct(obj)
|
||||
}
|
||||
|
||||
// CreateValidationError creates validation error
|
||||
func CreateValidationError(field string, value interface{}, rule, message string) Error {
|
||||
metadata := Metadata{
|
||||
"field": field,
|
||||
"value": value,
|
||||
"rule": rule,
|
||||
"validation_errors": map[string]interface{}{
|
||||
field: map[string]interface{}{
|
||||
"value": value,
|
||||
"rule": rule,
|
||||
"message": message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return NewWithMetadata(ErrCodeValidationFailed,
|
||||
fmt.Sprintf("Validation failed for field %s", field), metadata)
|
||||
}
|
||||
|
||||
// CreateValidationErrors creates error from multiple validation errors
|
||||
func CreateValidationErrors(errors ValidationErrors) Error {
|
||||
if len(errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(errors) == 1 {
|
||||
err := errors[0]
|
||||
return CreateValidationError(err.Field, err.Value, err.Rule, err.Message)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
"validation_errors": errors.ToMap(),
|
||||
"error_count": len(errors),
|
||||
}
|
||||
|
||||
return NewWithMetadata(ErrCodeValidationFailed,
|
||||
fmt.Sprintf("Validation failed with %d errors", len(errors)), metadata)
|
||||
}
|
||||
|
||||
// FieldValidation provides field-level validation
|
||||
type FieldValidation struct {
|
||||
Field string
|
||||
Value interface{}
|
||||
Rules []string
|
||||
}
|
||||
|
||||
// ValidateFields validates multiple fields
|
||||
func ValidateFields(validations []FieldValidation) ValidationErrors {
|
||||
v := DefaultValidator()
|
||||
var allErrors ValidationErrors
|
||||
|
||||
for _, validation := range validations {
|
||||
errors := v.ValidateField(validation.Field, validation.Value, validation.Rules)
|
||||
allErrors = append(allErrors, errors...)
|
||||
}
|
||||
|
||||
return allErrors
|
||||
}
|
||||
|
||||
// ValidationBuilder provides fluent API for validation
|
||||
type ValidationBuilder struct {
|
||||
validations []FieldValidation
|
||||
}
|
||||
|
||||
// NewValidationBuilder creates new validation builder
|
||||
func NewValidationBuilder() *ValidationBuilder {
|
||||
return &ValidationBuilder{
|
||||
validations: make([]FieldValidation, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Field adds field validation
|
||||
func (vb *ValidationBuilder) Field(field string, value interface{}) *FieldValidationBuilder {
|
||||
return &FieldValidationBuilder{
|
||||
builder: vb,
|
||||
field: field,
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// FieldValidationBuilder builds field validation
|
||||
type FieldValidationBuilder struct {
|
||||
builder *ValidationBuilder
|
||||
field string
|
||||
value interface{}
|
||||
}
|
||||
|
||||
// Rules adds validation rules
|
||||
func (fvb *FieldValidationBuilder) Rules(rules ...string) *ValidationBuilder {
|
||||
fvb.builder.validations = append(fvb.builder.validations, FieldValidation{
|
||||
Field: fvb.field,
|
||||
Value: fvb.value,
|
||||
Rules: rules,
|
||||
})
|
||||
return fvb.builder
|
||||
}
|
||||
|
||||
// Validate executes all validations
|
||||
func (vb *ValidationBuilder) Validate() ValidationErrors {
|
||||
return ValidateFields(vb.validations)
|
||||
}
|
||||
|
||||
// ValidateWithError executes validations and returns error
|
||||
func (vb *ValidationBuilder) ValidateWithError() Error {
|
||||
errors := vb.Validate()
|
||||
if len(errors) > 0 {
|
||||
return CreateValidationErrors(errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Logger interface mendefinisikan kontrak untuk logging
|
||||
type Logger interface {
|
||||
Debug(message string, fields ...Field)
|
||||
Info(message string, fields ...Field)
|
||||
Warn(message string, fields ...Field)
|
||||
Error(message string, fields ...Field)
|
||||
Fatal(message string, fields ...Field)
|
||||
WithContext(ctx context.Context) Logger
|
||||
WithFields(fields ...Field) Logger
|
||||
WithError(err error) Logger
|
||||
}
|
||||
|
||||
// Field mendefinisikan struktur data untuk logging fields
|
||||
type Field struct {
|
||||
Key string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Helper functions untuk membuat Field
|
||||
func String(key, value string) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Int(key string, value int) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Int64(key string, value int64) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Float64(key string, value float64) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Bool(key string, value bool) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func ErrorField(err error) Field {
|
||||
return Field{Key: "error", Value: err}
|
||||
}
|
||||
|
||||
func Duration(key string, value time.Duration) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Time(key string, value time.Time) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
func Any(key string, value interface{}) Field {
|
||||
return Field{Key: key, Value: value}
|
||||
}
|
||||
|
||||
// Config menyimpan konfigurasi untuk logger
|
||||
type Config struct {
|
||||
Level string
|
||||
Format string
|
||||
Output string
|
||||
FilePath string
|
||||
MaxSize int
|
||||
MaxBackups int
|
||||
MaxAge int
|
||||
Compress bool
|
||||
EnableCaller bool
|
||||
EnableStackTrace bool
|
||||
ServiceName string
|
||||
Environment string
|
||||
}
|
||||
|
||||
// DefaultConfig mengembalikan konfigurasi default
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Level: "info",
|
||||
Format: "json", // Format JSON menjadi default
|
||||
Output: "both", // Default langsung di set ke 'both' agar console & file aktif
|
||||
ServiceName: "service-general",
|
||||
Environment: "development",
|
||||
MaxSize: 100, // MB
|
||||
MaxBackups: 3,
|
||||
MaxAge: 7, // days
|
||||
Compress: true,
|
||||
}
|
||||
}
|
||||
|
||||
type loggerImpl struct {
|
||||
entry *logrus.Entry
|
||||
enableCaller bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
defaultLogger Logger
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Default mengembalikan logger default
|
||||
func Default() Logger {
|
||||
if defaultLogger == nil {
|
||||
Init(DefaultConfig())
|
||||
}
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
// New membuat instance logger baru yang independen (Ideal untuk Domain spesifik / CQRS)
|
||||
func New(cfg Config) Logger {
|
||||
logrusLogger := logrus.New()
|
||||
|
||||
// Set log level
|
||||
level, err := logrus.ParseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
logrusLogger.SetLevel(level)
|
||||
|
||||
// Set formatter
|
||||
switch cfg.Format {
|
||||
case "json":
|
||||
logrusLogger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
DisableHTMLEscape: true,
|
||||
PrettyPrint: false,
|
||||
})
|
||||
case "text":
|
||||
logrusLogger.SetFormatter(&customFormatter{})
|
||||
default:
|
||||
logrusLogger.SetFormatter(&customFormatter{})
|
||||
}
|
||||
|
||||
// Set output
|
||||
var output io.Writer
|
||||
switch cfg.Output {
|
||||
case "file":
|
||||
output = newDailyFileWriter("logs")
|
||||
case "both":
|
||||
fileWriter := newDailyFileWriter("logs")
|
||||
output = io.MultiWriter(os.Stdout, fileWriter)
|
||||
default:
|
||||
output = os.Stdout
|
||||
}
|
||||
logrusLogger.SetOutput(output)
|
||||
|
||||
// Set default fields
|
||||
entry := logrusLogger.WithFields(logrus.Fields{
|
||||
"service": cfg.ServiceName,
|
||||
"environment": cfg.Environment,
|
||||
})
|
||||
|
||||
return &loggerImpl{
|
||||
entry: entry,
|
||||
enableCaller: cfg.EnableCaller,
|
||||
}
|
||||
}
|
||||
|
||||
// Init menginisialisasi logger global (default)
|
||||
func Init(cfg Config) Logger {
|
||||
once.Do(func() {
|
||||
defaultLogger = New(cfg)
|
||||
})
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
func (l *loggerImpl) WithContext(ctx context.Context) Logger {
|
||||
if ctx == nil {
|
||||
return l
|
||||
}
|
||||
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
fields := make(logrus.Fields)
|
||||
|
||||
// Extract standard identifiers automatically
|
||||
keys := []string{"trace_id", "request_id", "correlation_id", "user_id"}
|
||||
for _, key := range keys {
|
||||
if val := ctx.Value(key); val != nil {
|
||||
fields[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
return &loggerImpl{
|
||||
entry: l.entry.WithFields(fields),
|
||||
enableCaller: l.enableCaller,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loggerImpl) WithFields(fields ...Field) Logger {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
logrusFields := make(logrus.Fields)
|
||||
for _, field := range fields {
|
||||
logrusFields[field.Key] = field.Value
|
||||
}
|
||||
|
||||
return &loggerImpl{
|
||||
entry: l.entry.WithFields(logrusFields),
|
||||
enableCaller: l.enableCaller,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loggerImpl) WithError(err error) Logger {
|
||||
if err == nil {
|
||||
return l // Mencegah Panic bila log dipanggil dengan err == nil
|
||||
}
|
||||
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
fields := logrus.Fields{
|
||||
"error": err.Error(),
|
||||
}
|
||||
|
||||
// Add stack trace if available
|
||||
if stackErr, ok := err.(interface{ StackTrace() []string }); ok {
|
||||
fields["stack_trace"] = stackErr.StackTrace()
|
||||
}
|
||||
|
||||
return &loggerImpl{
|
||||
entry: l.entry.WithFields(fields),
|
||||
enableCaller: l.enableCaller,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loggerImpl) log(level logrus.Level, message string, fields ...Field) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
entry := l.entry
|
||||
|
||||
// Add caller information (hanya jika EnableCaller dikonfigurasi aktif)
|
||||
if l.enableCaller {
|
||||
if pc, file, line, ok := runtime.Caller(2); ok {
|
||||
funcName := runtime.FuncForPC(pc).Name()
|
||||
entry = entry.WithFields(logrus.Fields{
|
||||
"caller": fmt.Sprintf("%s:%d", filepath.Base(file), line),
|
||||
"function": funcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convert fields to logrus fields
|
||||
if len(fields) > 0 {
|
||||
logrusFields := make(logrus.Fields)
|
||||
for _, field := range fields {
|
||||
logrusFields[field.Key] = field.Value
|
||||
}
|
||||
entry = entry.WithFields(logrusFields)
|
||||
}
|
||||
|
||||
switch level {
|
||||
case logrus.DebugLevel:
|
||||
entry.Debug(message)
|
||||
case logrus.InfoLevel:
|
||||
entry.Info(message)
|
||||
case logrus.WarnLevel:
|
||||
entry.Warn(message)
|
||||
case logrus.ErrorLevel:
|
||||
entry.Error(message)
|
||||
case logrus.FatalLevel:
|
||||
entry.Fatal(message)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loggerImpl) Debug(message string, fields ...Field) {
|
||||
l.log(logrus.DebugLevel, message, fields...)
|
||||
}
|
||||
|
||||
func (l *loggerImpl) Info(message string, fields ...Field) {
|
||||
l.log(logrus.InfoLevel, message, fields...)
|
||||
}
|
||||
|
||||
func (l *loggerImpl) Warn(message string, fields ...Field) {
|
||||
l.log(logrus.WarnLevel, message, fields...)
|
||||
}
|
||||
|
||||
func (l *loggerImpl) Error(message string, fields ...Field) {
|
||||
l.log(logrus.ErrorLevel, message, fields...)
|
||||
}
|
||||
|
||||
func (l *loggerImpl) Fatal(message string, fields ...Field) {
|
||||
l.log(logrus.FatalLevel, message, fields...)
|
||||
}
|
||||
|
||||
// Helper functions untuk backward compatibility
|
||||
func fieldsToMap(fields ...Field) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
for _, field := range fields {
|
||||
result[field.Key] = field.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Global functions untuk backward compatibility
|
||||
func Debug(message string, fields map[string]interface{}) {
|
||||
logger := Default()
|
||||
var fieldSlice []Field
|
||||
for k, v := range fields {
|
||||
fieldSlice = append(fieldSlice, Any(k, v))
|
||||
}
|
||||
logger.Debug(message, fieldSlice...)
|
||||
}
|
||||
|
||||
func Info(message string, fields map[string]interface{}) {
|
||||
logger := Default()
|
||||
var fieldSlice []Field
|
||||
for k, v := range fields {
|
||||
fieldSlice = append(fieldSlice, Any(k, v))
|
||||
}
|
||||
logger.Info(message, fieldSlice...)
|
||||
}
|
||||
|
||||
func Warn(message string, fields map[string]interface{}) {
|
||||
logger := Default()
|
||||
var fieldSlice []Field
|
||||
for k, v := range fields {
|
||||
fieldSlice = append(fieldSlice, Any(k, v))
|
||||
}
|
||||
logger.Warn(message, fieldSlice...)
|
||||
}
|
||||
|
||||
func Error(message string, fields map[string]interface{}) {
|
||||
logger := Default()
|
||||
var fieldSlice []Field
|
||||
for k, v := range fields {
|
||||
fieldSlice = append(fieldSlice, Any(k, v))
|
||||
}
|
||||
logger.Error(message, fieldSlice...)
|
||||
}
|
||||
|
||||
func Fatal(message string, fields map[string]interface{}) {
|
||||
logger := Default()
|
||||
var fieldSlice []Field
|
||||
for k, v := range fields {
|
||||
fieldSlice = append(fieldSlice, Any(k, v))
|
||||
}
|
||||
logger.Fatal(message, fieldSlice...)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM DAILY DIRECTORY WRITER
|
||||
// ============================================================================
|
||||
|
||||
// dailyFileWriter adalah custom io.Writer untuk menulis log ke dalam struktur folder bulanan (logs/YYYY/MM/YYYY-MM-DD.log)
|
||||
type dailyFileWriter struct {
|
||||
mu sync.Mutex
|
||||
basePath string
|
||||
currDate string
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func newDailyFileWriter(basePath string) *dailyFileWriter {
|
||||
if basePath == "" {
|
||||
basePath = "logs"
|
||||
}
|
||||
return &dailyFileWriter{basePath: basePath}
|
||||
}
|
||||
|
||||
func (w *dailyFileWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
dateStr := now.Format("2006-01-02") // Format harian (YYYY-MM-DD)
|
||||
|
||||
// Cek jika hari berganti atau file belum terbuka
|
||||
if w.currDate != dateStr || w.file == nil {
|
||||
if w.file != nil {
|
||||
w.file.Close()
|
||||
}
|
||||
|
||||
year := now.Format("2006")
|
||||
month := now.Format("01")
|
||||
|
||||
// Buat struktur direktori logs/YYYY/MM
|
||||
dir := filepath.Join(w.basePath, year, month)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Buka / Buat file logs/YYYY/MM/YYYY-MM-DD.log
|
||||
filename := filepath.Join(dir, dateStr+".log")
|
||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
w.file = file
|
||||
w.currDate = dateStr
|
||||
}
|
||||
|
||||
return w.file.Write(p)
|
||||
}
|
||||
|
||||
func (w *dailyFileWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file != nil {
|
||||
return w.file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM LOG FORMATTER (READABLE TEXT FORMAT)
|
||||
// ============================================================================
|
||||
|
||||
type customFormatter struct{}
|
||||
|
||||
func (f *customFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
var b *bytes.Buffer
|
||||
if entry.Buffer != nil {
|
||||
b = entry.Buffer
|
||||
} else {
|
||||
b = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
timestamp := entry.Time.Format("2006-01-02 15:04:05.000")
|
||||
level := strings.ToUpper(entry.Level.String())
|
||||
if len(level) > 5 {
|
||||
level = level[:5]
|
||||
}
|
||||
|
||||
// 1. Base Format: [TIMESTAMP] [LEVEL] MESSAGE (Lebar di-fix agar sejajar)
|
||||
fmt.Fprintf(b, "[%s] [%-5s] %-55s", timestamp, level, entry.Message)
|
||||
|
||||
// 2. Extract and Sort Keys agar log selalu konsisten urutannya
|
||||
keys := make([]string, 0, len(entry.Data))
|
||||
for k := range entry.Data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// 3. Append metadata dengan pembatas " | "
|
||||
for _, k := range keys {
|
||||
v := entry.Data[k]
|
||||
switch k {
|
||||
case "error":
|
||||
fmt.Fprintf(b, " | ❌ ERROR: %v", v)
|
||||
case "caller":
|
||||
fmt.Fprintf(b, " | 📍 %v", v)
|
||||
case "function":
|
||||
fmt.Fprintf(b, " | ⚙️ %v", v)
|
||||
default:
|
||||
fmt.Fprintf(b, " | %s: %v", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response represents a standard API response
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Success sends a success response
|
||||
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
c.JSON(statusCode, Response{
|
||||
Status: "success",
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Error sends an error response
|
||||
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
|
||||
// Handle jika tipe yang dikirim adalah native error Go agar tidak menjadi objek kosong '{}' di JSON
|
||||
if e, ok := err.(error); ok {
|
||||
err = e.Error()
|
||||
}
|
||||
|
||||
c.JSON(statusCode, Response{
|
||||
Status: "error",
|
||||
Message: message,
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorWithLog mengirimkan respons error HTTP sekaligus menyisipkan error asli
|
||||
// ke dalam context Gin agar bisa direkam oleh LoggingMiddleware.
|
||||
func ErrorWithLog(c *gin.Context, err error, statusCode int, message string, details interface{}) {
|
||||
if err != nil {
|
||||
c.Error(err) // Meneruskan error asli ke Gin Context untuk dicatat logger
|
||||
}
|
||||
Error(c, statusCode, message, details) // Panggil fungsi Error() standar
|
||||
}
|
||||
|
||||
// Meta contains pagination metadata
|
||||
type Meta struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// Paginated sends a paginated response
|
||||
func Paginated(c *gin.Context, statusCode int, message string, data interface{}, meta Meta) {
|
||||
c.JSON(statusCode, Response{
|
||||
Status: "success",
|
||||
Message: message,
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// BPJS FORMATTER
|
||||
// =========================================================================
|
||||
|
||||
// BPJSMetaData merepresentasikan metadata standar dari API BPJS
|
||||
type BPJSMetaData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BPJSResponse merepresentasikan standar response API BPJS
|
||||
type BPJSResponse struct {
|
||||
MetaData BPJSMetaData `json:"metaData"`
|
||||
Response interface{} `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
// BPJS mengirimkan respons dengan format standar API BPJS
|
||||
func BPJS(c *gin.Context, httpStatusCode int, bpjsCode string, message string, data interface{}) {
|
||||
c.JSON(httpStatusCode, BPJSResponse{
|
||||
MetaData: BPJSMetaData{
|
||||
Code: bpjsCode, // Contoh: "200" (sukses), "201" (dibuat), atau kode error BPJS lainnya
|
||||
Message: message,
|
||||
},
|
||||
Response: data,
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SATU SEHAT (HL7 FHIR) FORMATTER
|
||||
// =========================================================================
|
||||
|
||||
// FHIROperationOutcome merepresentasikan struktur error standar HL7 FHIR
|
||||
type FHIROperationOutcome struct {
|
||||
ResourceType string `json:"resourceType"` // Harus selalu "OperationOutcome"
|
||||
Issue []FHIRErrorIssue `json:"issue"`
|
||||
}
|
||||
|
||||
// FHIRErrorIssue berisi detail issue untuk error Satu Sehat
|
||||
type FHIRErrorIssue struct {
|
||||
Severity string `json:"severity"` // fatal | error | warning | information
|
||||
Code string `json:"code"` // invalid | security | exception | not-found | dll
|
||||
Diagnostics string `json:"diagnostics"` // Pesan detail / human-readable
|
||||
}
|
||||
|
||||
// FHIR mengirimkan respons untuk resource FHIR secara langsung (standar Satu Sehat)
|
||||
// Satu Sehat tidak menggunakan wrapper "data" atau "status", melainkan me-return object Resource langsung
|
||||
func FHIR(c *gin.Context, statusCode int, resource interface{}) {
|
||||
c.JSON(statusCode, resource)
|
||||
}
|
||||
|
||||
// FHIRError mengirimkan pesan error yang comply dengan format OperationOutcome FHIR
|
||||
func FHIRError(c *gin.Context, statusCode int, severity, code, diagnostics string) {
|
||||
c.JSON(statusCode, FHIROperationOutcome{
|
||||
ResourceType: "OperationOutcome",
|
||||
Issue: []FHIRErrorIssue{
|
||||
{
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Diagnostics: diagnostics,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
# 📚 Manual Penggunaan Lengkap Pustaka Query Builder
|
||||
|
||||
Dokumen ini merupakan panduan komprehensif untuk menggunakan pustaka `Query Builder` (berada di `pkg/utils/query`). Pustaka ini dirancang sebagai abstraksi query dinamis yang aman dari *SQL Injection*, mendukung berbagai dialek SQL (PostgreSQL, MySQL, SQL Server, SQLite), MongoDB, serta kompatibel penuh dengan operasi transaksi.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Daftar Isi
|
||||
1. Inisialisasi & Konfigurasi
|
||||
2. Fungsi Helper (Membuat Query Cepat)
|
||||
3. Penggunaan Sederhana (CRUD Dasar SQL)
|
||||
4. Penggunaan Lanjutan (Advanced SQL)
|
||||
5. Transaksi (Transactions)
|
||||
6. Dukungan MongoDB
|
||||
7. Query Parser (API URL Parameter)
|
||||
8. Daftar Operator Filter
|
||||
|
||||
---
|
||||
|
||||
## 1. Inisialisasi & Konfigurasi
|
||||
|
||||
Pustaka ini menyediakan beberapa cara untuk inisialisasi, baik menggunakan *Builder* langsung maupun melalui *Query Manager* yang mengumpulkan fungsi SQL, MongoDB, dan Parser di satu tempat.
|
||||
|
||||
```go
|
||||
import "your_project/pkg/utils/query"
|
||||
|
||||
// A. Menggunakan Manager (Direkomendasikan)
|
||||
qm := query.NewQueryManager(query.DBTypePostgreSQL)
|
||||
sqlBuilder := qm.GetSQLBuilder()
|
||||
mongoBuilder := qm.GetMongoBuilder()
|
||||
parser := qm.GetQueryParser()
|
||||
|
||||
// B. Menggunakan Instansiasi Spesifik Dialek
|
||||
pgBuilder := query.ForPostgreSQL()
|
||||
mysqlBuilder := query.ForMySQL()
|
||||
mongoBuilder := query.ForMongoDB()
|
||||
|
||||
// C. Menggunakan Konfigurasi Keamanan (Sangat Disarankan)
|
||||
sqlBuilder.
|
||||
SetSecurityOptions(true, 1000). // Enable security & set max rows
|
||||
SetAllowedTables([]string{"users"}). // Whitelist tabel
|
||||
SetAllowedColumns([]string{"id", "name"}).// Whitelist kolom
|
||||
SetQueryLogging(true). // Aktifkan log query
|
||||
SetQueryTimeout(30 * time.Second) // Timeout query
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Penggunaan Sederhana (CRUD Dasar)
|
||||
|
||||
*Query Builder* menggunakan tipe `sqlx.ExtContext` untuk eksekutornya, yang berarti Anda dapat melemparkan `*sqlx.DB` (koneksi normal) maupun `*sqlx.Tx` (transaksi) ke dalamnya.
|
||||
|
||||
### A. SELECT (Mengambil Data)
|
||||
|
||||
```go
|
||||
var users []User
|
||||
|
||||
// Definisi struktur query
|
||||
q := query.DynamicQuery{
|
||||
From: "users",
|
||||
Fields: []query.SelectField{
|
||||
{Expression: "id"},
|
||||
{Expression: "name"},
|
||||
},
|
||||
Filters: []query.FilterGroup{{
|
||||
LogicOp: "AND",
|
||||
Filters: []query.DynamicFilter{
|
||||
query.CreateEqualFilter("status", "active"),
|
||||
query.CreateFilter("age", query.OpGreaterThan, 18),
|
||||
},
|
||||
}},
|
||||
Sort: []query.SortField{
|
||||
query.CreateDescSort("created_at"),
|
||||
},
|
||||
Limit: 10,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
// Eksekusi query (db adalah *sqlx.DB)
|
||||
err := sqlBuilder.ExecuteQuery(ctx, db, q, &users)
|
||||
```
|
||||
|
||||
### B. INSERT (Menambah Data)
|
||||
|
||||
```go
|
||||
insertData := query.InsertData{
|
||||
Columns: []string{"name", "email", "status"},
|
||||
Values: []interface{}{"John Doe", "[email protected]", "active"},
|
||||
}
|
||||
|
||||
// Eksekusi Insert (mengembalikan sql.Result)
|
||||
// Opsional: tambahkan kolom di akhir parameter untuk klausa RETURNING (hanya di Postgres)
|
||||
result, err := sqlBuilder.ExecuteInsert(ctx, db, "users", insertData, "id")
|
||||
```
|
||||
|
||||
### C. UPDATE (Mengubah Data)
|
||||
|
||||
```go
|
||||
updateData := query.UpdateData{
|
||||
Columns: []string{"status"},
|
||||
Values: []interface{}{"inactive"},
|
||||
}
|
||||
|
||||
filters := []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{
|
||||
query.CreateEqualFilter("id", 1),
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := sqlBuilder.ExecuteUpdate(ctx, db, "users", updateData, filters)
|
||||
```
|
||||
|
||||
### D. DELETE (Menghapus Data)
|
||||
|
||||
```go
|
||||
filters := []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{
|
||||
query.CreateEqualFilter("id", 1),
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := sqlBuilder.ExecuteDelete(ctx, db, "users", filters)
|
||||
```
|
||||
|
||||
### E. COUNT (Menghitung Jumlah Baris)
|
||||
|
||||
```go
|
||||
q := query.DynamicQuery{
|
||||
From: "users",
|
||||
Filters: []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{
|
||||
query.CreateEqualFilter("status", "active"),
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
count, err := sqlBuilder.ExecuteCount(ctx, db, q)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Penggunaan Bertingkat / Lanjutan
|
||||
|
||||
### A. JOIN Tables
|
||||
|
||||
Mendukung relasi tabel (Inner, Left, Right, Full, dan Lateral Joins).
|
||||
|
||||
```go
|
||||
q := query.DynamicQuery{
|
||||
From: "orders",
|
||||
Aliases: "o",
|
||||
Fields: []query.SelectField{
|
||||
{Expression: "o.id"},
|
||||
{Expression: "u.name", Alias: "customer_name"},
|
||||
},
|
||||
Joins: []query.Join{
|
||||
{
|
||||
Type: "LEFT",
|
||||
Table: "users",
|
||||
Alias: "u",
|
||||
OnConditions: query.FilterGroup{
|
||||
Filters: []query.DynamicFilter{
|
||||
// Bandingkan kolom dengan kolom lainnya (tanpa di-treat sebagai string biasa)
|
||||
{Column: "o.user_id", Operator: query.OpEqual, Value: "u.id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### B. Common Table Expressions (CTE) / Klausa WITH
|
||||
|
||||
Berguna untuk membuat sub-query *temporary* yang dapat digunakan dalam query utama.
|
||||
|
||||
```go
|
||||
q := query.DynamicQuery{
|
||||
CTEs: []query.CTE{
|
||||
{
|
||||
Name: "active_users",
|
||||
Query: query.DynamicQuery{
|
||||
From: "users",
|
||||
Filters: []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{query.CreateEqualFilter("status", "active")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
From: "active_users", // Query utama select dari CTE di atas
|
||||
}
|
||||
```
|
||||
|
||||
### C. Window Functions
|
||||
|
||||
Mendukung eksekusi fungsi analitik (cth: `ROW_NUMBER()`, `RANK()`).
|
||||
|
||||
```go
|
||||
q := query.DynamicQuery{
|
||||
From: "sales",
|
||||
Fields: []query.SelectField{
|
||||
{Expression: "employee_id"},
|
||||
{Expression: "amount"},
|
||||
},
|
||||
WindowFunctions: []query.WindowFunction{
|
||||
{
|
||||
Function: "RANK",
|
||||
Over: "PARTITION BY department_id",
|
||||
OrderBy: "amount DESC",
|
||||
Alias: "sales_rank",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### D. Operasi JSON
|
||||
|
||||
Mendukung *query* untuk kolom tipe JSON/JSONB pada tabel relasional.
|
||||
|
||||
```go
|
||||
// Mencari dari data JSON
|
||||
q := query.DynamicQuery{
|
||||
From: "products",
|
||||
Filters: []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{
|
||||
{
|
||||
Column: "attributes",
|
||||
Operator: query.OpJsonEqual,
|
||||
Value: "red",
|
||||
Options: map[string]interface{}{"path": "color"}, // Akan di-parse: attributes->'color' = 'red'
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Penggunaan dengan Transaksi (Transaction)
|
||||
|
||||
`QueryBuilder` telah dirancang untuk mendukung *Transactions* secara mulus. Semua metode eksekusi (seperti `ExecuteInsert`, `ExecuteUpdate`, `ExecuteQuery`) menerima tipe parameter antarmuka `sqlx.ExtContext`.
|
||||
|
||||
Oleh karena itu, Anda cukup memberikan variabel `*sqlx.Tx` menggantikan `*sqlx.DB`!
|
||||
|
||||
**Contoh Transaksi Lengkap:**
|
||||
|
||||
```go
|
||||
func CreateUserWithProfile(ctx context.Context, db *sqlx.DB, sqlBuilder query.QueryBuilder) error {
|
||||
// 1. Mulai Transaksi
|
||||
tx, err := db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buat fungsi defer untuk auto-rollback jika terjadi panic / error tanpa komit
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
tx.Rollback()
|
||||
panic(p)
|
||||
} else if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. Operasi PERTAMA dalam transaksi (Insert User)
|
||||
userData := query.InsertData{
|
||||
Columns: []string{"name", "email"},
|
||||
Values: []interface{}{"Jane Doe", "[email protected]"},
|
||||
}
|
||||
|
||||
// PERHATIKAN: Kita melemparkan "tx", bukan "db"
|
||||
// Kita menggunakan klausa returning "id" untuk mendapatkan ID user yang baru
|
||||
res, err := sqlBuilder.ExecuteInsert(ctx, tx, "users", userData, "id")
|
||||
if err != nil {
|
||||
return err // Akan trigger tx.Rollback() melalui defer
|
||||
}
|
||||
|
||||
// (Jika pakai PostgreSQL, ambil ID melalui interface driver, atau pakai ExecuteQueryRow)
|
||||
var newUserID int64 = 1 // Simulasi ID yang didapat
|
||||
|
||||
// 3. Operasi KEDUA dalam transaksi (Insert Profile)
|
||||
profileData := query.InsertData{
|
||||
Columns: []string{"user_id", "bio"},
|
||||
Values: []interface{}{newUserID, "Hello I am Jane"},
|
||||
}
|
||||
|
||||
_, err = sqlBuilder.ExecuteInsert(ctx, tx, "profiles", profileData)
|
||||
if err != nil {
|
||||
return err // Akan trigger tx.Rollback() melalui defer
|
||||
}
|
||||
|
||||
// 4. Komit Transaksi Jika Semua Berhasil
|
||||
err = tx.Commit()
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dukungan MongoDB
|
||||
|
||||
Selain SQL, *Query Builder* juga menyediakan antarmuka khusus untuk MongoDB melalui `MongoQueryBuilder`. Objek `DynamicQuery` yang sama bisa digunakan untuk mem-parsing sintaks MongoDB (sehingga cocok dengan arsitektur multi-database).
|
||||
|
||||
```go
|
||||
// Inisialisasi
|
||||
mongoQb := query.CreateMongoQueryBuilder()
|
||||
|
||||
// Objek Dynamic Query (Sama seperti SQL)
|
||||
q := query.DynamicQuery{
|
||||
Filters: []query.FilterGroup{{
|
||||
Filters: []query.DynamicFilter{
|
||||
query.CreateEqualFilter("status", "active"),
|
||||
},
|
||||
}},
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
// Eksekusi Find
|
||||
var results []bson.M
|
||||
// Parameter kedua adalah objek *mongo.Collection
|
||||
err := mongoQb.ExecuteFind(ctx, myMongoCollection, q, &results)
|
||||
```
|
||||
|
||||
Mendukung operasi MongoDB lain seperti `ExecuteCount`, `ExecuteInsert`, `ExecuteUpdate`, `ExecuteDelete`, dan `ExecuteAggregate`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Parser URL (Mengubah Parameter API menjadi Query)
|
||||
|
||||
Sangat bermanfaat untuk skenario REST API di mana _client_ memberikan filter, sort, dan paginasi melalui *query string* (misal: `?limit=10&sort=-created_at&filter[status][_eq]=active`).
|
||||
|
||||
```go
|
||||
func GetUsersHandler(c *gin.Context) {
|
||||
// Ambil parameter querystring dari request URL
|
||||
urlParams := c.Request.URL.Query()
|
||||
|
||||
// Inisialisasi Parser
|
||||
parser := query.CreateQueryParser().SetLimits(10, 100)
|
||||
|
||||
// Konversi URL Parameters menjadi objek DynamicQuery
|
||||
dynamicQuery, err := parser.ParseQuery(urlParams, "users")
|
||||
if err != nil {
|
||||
// Handle error (bad request)
|
||||
}
|
||||
|
||||
// Eksekusi query dengan SQL Builder
|
||||
var users []User
|
||||
sqlBuilder.ExecuteQuery(ctx, db, dynamicQuery, &users)
|
||||
|
||||
// Return response...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,403 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
// Main entry point for backward compatibility
|
||||
// This file provides the main interface and factory functions
|
||||
|
||||
// QueryBuilderFactory creates query builders for different database types
|
||||
type QueryBuilderFactory interface {
|
||||
CreateSQLQueryBuilder(dbType DBType) QueryBuilder
|
||||
CreateMongoQueryBuilder() MongoQueryBuilder
|
||||
CreateQueryParser() QueryParser
|
||||
}
|
||||
|
||||
// DefaultQueryBuilderFactory implements QueryBuilderFactory
|
||||
type DefaultQueryBuilderFactory struct{}
|
||||
|
||||
// NewQueryBuilderFactory creates a new query builder factory
|
||||
func NewQueryBuilderFactory() QueryBuilderFactory {
|
||||
return &DefaultQueryBuilderFactory{}
|
||||
}
|
||||
|
||||
// CreateSQLQueryBuilder creates a new SQL query builder
|
||||
func (f *DefaultQueryBuilderFactory) CreateSQLQueryBuilder(dbType DBType) QueryBuilder {
|
||||
return NewSQLQueryBuilder(dbType)
|
||||
}
|
||||
|
||||
// CreateMongoQueryBuilder creates a new MongoDB query builder
|
||||
func (f *DefaultQueryBuilderFactory) CreateMongoQueryBuilder() MongoQueryBuilder {
|
||||
return NewMongoQueryBuilder()
|
||||
}
|
||||
|
||||
// CreateQueryParser creates a new query parser
|
||||
func (f *DefaultQueryBuilderFactory) CreateQueryParser() QueryParser {
|
||||
return NewQueryParser()
|
||||
}
|
||||
|
||||
// QueryManager provides a unified interface for all query operations
|
||||
type QueryManager struct {
|
||||
SQLBuilder QueryBuilder
|
||||
MongoBuilder MongoQueryBuilder
|
||||
QueryParser QueryParser
|
||||
}
|
||||
|
||||
// NewQueryManager creates a new query manager with all builders
|
||||
func NewQueryManager(dbType DBType) *QueryManager {
|
||||
return &QueryManager{
|
||||
SQLBuilder: NewSQLQueryBuilder(dbType),
|
||||
MongoBuilder: NewMongoQueryBuilder(),
|
||||
QueryParser: NewQueryParser(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewQueryManagerWithFactory creates a new query manager using factory
|
||||
func NewQueryManagerWithFactory(factory QueryBuilderFactory, dbType DBType) *QueryManager {
|
||||
return &QueryManager{
|
||||
SQLBuilder: factory.CreateSQLQueryBuilder(dbType),
|
||||
MongoBuilder: factory.CreateMongoQueryBuilder(),
|
||||
QueryParser: factory.CreateQueryParser(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSQLBuilder returns the SQL query builder
|
||||
func (qm *QueryManager) GetSQLBuilder() QueryBuilder {
|
||||
return qm.SQLBuilder
|
||||
}
|
||||
|
||||
// GetMongoBuilder returns the MongoDB query builder
|
||||
func (qm *QueryManager) GetMongoBuilder() MongoQueryBuilder {
|
||||
return qm.MongoBuilder
|
||||
}
|
||||
|
||||
// GetQueryParser returns the query parser
|
||||
func (qm *QueryManager) GetQueryParser() QueryParser {
|
||||
return qm.QueryParser
|
||||
}
|
||||
|
||||
// ExecuteSQLQuery executes a SQL query with the SQL builder
|
||||
func (qm *QueryManager) ExecuteSQLQuery(ctx context.Context, db *sqlx.DB, query DynamicQuery, dest interface{}) error {
|
||||
return qm.SQLBuilder.ExecuteQuery(ctx, db, query, dest)
|
||||
}
|
||||
|
||||
// ExecuteMongoQuery executes a MongoDB query with the MongoDB builder
|
||||
func (qm *QueryManager) ExecuteMongoQuery(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
|
||||
return qm.MongoBuilder.ExecuteFind(ctx, collection, query, dest)
|
||||
}
|
||||
|
||||
// ParseQuery parses URL query parameters into a DynamicQuery
|
||||
func (qm *QueryManager) ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error) {
|
||||
return qm.QueryParser.ParseQuery(values, defaultTable)
|
||||
}
|
||||
|
||||
// ParseQueryWithDefaultFields parses URL query parameters with default fields
|
||||
func (qm *QueryManager) ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
|
||||
return qm.QueryParser.ParseQueryWithDefaultFields(values, defaultTable, defaultFields)
|
||||
}
|
||||
|
||||
// Convenience functions for common operations
|
||||
|
||||
// CreateQueryBuilder creates a query builder with default settings
|
||||
func CreateQueryBuilder(dbType DBType) QueryBuilder {
|
||||
return NewSQLQueryBuilder(dbType).
|
||||
SetSecurityOptions(true, 1000).
|
||||
SetQueryLogging(true).
|
||||
SetQueryTimeout(30)
|
||||
}
|
||||
|
||||
// CreateMongoQueryBuilder creates a MongoDB query builder with default settings
|
||||
func CreateMongoQueryBuilder() MongoQueryBuilder {
|
||||
return NewMongoQueryBuilder().
|
||||
SetSecurityOptions(true, 1000).
|
||||
SetQueryLogging(true).
|
||||
SetQueryTimeout(30)
|
||||
}
|
||||
|
||||
// CreateQueryParser creates a query parser with default settings
|
||||
func CreateQueryParser() QueryParser {
|
||||
return NewQueryParser().SetLimits(10, 100)
|
||||
}
|
||||
|
||||
// ExecuteQuery is a convenience function for executing SQL queries
|
||||
func ExecuteQuery(ctx context.Context, db *sqlx.DB, dbType DBType, query DynamicQuery, dest interface{}) error {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteQuery(ctx, db, query, dest)
|
||||
}
|
||||
|
||||
// ExecuteMongoQuery is a convenience function for executing MongoDB queries
|
||||
func ExecuteMongoQuery(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteFind(ctx, collection, query, dest)
|
||||
}
|
||||
|
||||
// ParseURLQuery is a convenience function for parsing URL queries
|
||||
func ParseURLQuery(urlValues interface{}, defaultTable string) (DynamicQuery, error) {
|
||||
parser := CreateQueryParser()
|
||||
return parser.ParseQuery(urlValues, defaultTable)
|
||||
}
|
||||
|
||||
// ParseURLQueryWithFields is a convenience function for parsing URL queries with default fields
|
||||
func ParseURLQueryWithFields(urlValues interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
|
||||
parser := CreateQueryParser()
|
||||
return parser.ParseQueryWithDefaultFields(urlValues, defaultTable, defaultFields)
|
||||
}
|
||||
|
||||
// Database-specific helpers
|
||||
|
||||
// ForPostgreSQL creates a PostgreSQL-specific query builder
|
||||
func ForPostgreSQL() QueryBuilder {
|
||||
return NewSQLQueryBuilder(DBTypePostgreSQL)
|
||||
}
|
||||
|
||||
// ForMySQL creates a MySQL-specific query builder
|
||||
func ForMySQL() QueryBuilder {
|
||||
return NewSQLQueryBuilder(DBTypeMySQL)
|
||||
}
|
||||
|
||||
// ForSQLite creates a SQLite-specific query builder
|
||||
func ForSQLite() QueryBuilder {
|
||||
return NewSQLQueryBuilder(DBTypeSQLite)
|
||||
}
|
||||
|
||||
// ForSQLServer creates a SQL Server-specific query builder
|
||||
func ForSQLServer() QueryBuilder {
|
||||
return NewSQLQueryBuilder(DBTypeSQLServer)
|
||||
}
|
||||
|
||||
// ForMongoDB creates a MongoDB-specific query builder
|
||||
func ForMongoDB() MongoQueryBuilder {
|
||||
return NewMongoQueryBuilder()
|
||||
}
|
||||
|
||||
// Query execution helpers
|
||||
|
||||
// ExecuteCount executes a count query
|
||||
func ExecuteCount(ctx context.Context, db *sqlx.DB, dbType DBType, query DynamicQuery) (int64, error) {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteCount(ctx, db, query)
|
||||
}
|
||||
|
||||
// ExecuteInsert executes an insert query
|
||||
func ExecuteInsert(ctx context.Context, db *sqlx.DB, dbType DBType, table string, data InsertData, returningColumns ...string) (sql.Result, error) {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteInsert(ctx, db, table, data, returningColumns...)
|
||||
}
|
||||
|
||||
// ExecuteUpdate executes an update query
|
||||
func ExecuteUpdate(ctx context.Context, db *sqlx.DB, dbType DBType, table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteUpdate(ctx, db, table, updateData, filters, returningColumns...)
|
||||
}
|
||||
|
||||
// ExecuteDelete executes a delete query
|
||||
func ExecuteDelete(ctx context.Context, db *sqlx.DB, dbType DBType, table string, filters []FilterGroup, returningColumns ...string) (sql.Result, error) {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteDelete(ctx, db, table, filters, returningColumns...)
|
||||
}
|
||||
|
||||
// ExecuteUpsert executes an upsert query
|
||||
func ExecuteUpsert(ctx context.Context, db *sqlx.DB, dbType DBType, table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (sql.Result, error) {
|
||||
builder := CreateQueryBuilder(dbType)
|
||||
return builder.ExecuteUpsert(ctx, db, table, insertData, conflictColumns, updateColumns, returningColumns...)
|
||||
}
|
||||
|
||||
// MongoDB execution helpers
|
||||
|
||||
// ExecuteMongoCount executes a MongoDB count query
|
||||
func ExecuteMongoCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error) {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteCount(ctx, collection, query)
|
||||
}
|
||||
|
||||
// ExecuteMongoInsert executes a MongoDB insert query
|
||||
func ExecuteMongoInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error) {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteInsert(ctx, collection, data)
|
||||
}
|
||||
|
||||
// ExecuteMongoUpdate executes a MongoDB update query
|
||||
func ExecuteMongoUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error) {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteUpdate(ctx, collection, updateData, filters)
|
||||
}
|
||||
|
||||
// ExecuteMongoDelete executes a MongoDB delete query
|
||||
func ExecuteMongoDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error) {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteDelete(ctx, collection, filters)
|
||||
}
|
||||
|
||||
// ExecuteMongoAggregate executes a MongoDB aggregation query
|
||||
func ExecuteMongoAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
|
||||
builder := CreateMongoQueryBuilder()
|
||||
return builder.ExecuteAggregate(ctx, collection, query, dest)
|
||||
}
|
||||
|
||||
// Utility functions for creating common queries
|
||||
|
||||
// CreateSimpleQuery creates a simple query with basic fields
|
||||
func CreateSimpleQuery(table string, fields []string, limit int) DynamicQuery {
|
||||
selectFields := make([]SelectField, len(fields))
|
||||
for i, field := range fields {
|
||||
selectFields[i] = SelectField{Expression: field}
|
||||
}
|
||||
|
||||
return DynamicQuery{
|
||||
From: table,
|
||||
Fields: selectFields,
|
||||
Limit: limit,
|
||||
Offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFilterQuery creates a query with filters
|
||||
func CreateFilterQuery(table string, fields []string, filters []DynamicFilter, limit int) DynamicQuery {
|
||||
selectFields := make([]SelectField, len(fields))
|
||||
for i, field := range fields {
|
||||
selectFields[i] = SelectField{Expression: field}
|
||||
}
|
||||
|
||||
return DynamicQuery{
|
||||
From: table,
|
||||
Fields: selectFields,
|
||||
Filters: []FilterGroup{{Filters: filters, LogicOp: "AND"}},
|
||||
Limit: limit,
|
||||
Offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSortedQuery creates a query with sorting
|
||||
func CreateSortedQuery(table string, fields []string, sorts []SortField, limit int) DynamicQuery {
|
||||
selectFields := make([]SelectField, len(fields))
|
||||
for i, field := range fields {
|
||||
selectFields[i] = SelectField{Expression: field}
|
||||
}
|
||||
|
||||
return DynamicQuery{
|
||||
From: table,
|
||||
Fields: selectFields,
|
||||
Sort: sorts,
|
||||
Limit: limit,
|
||||
Offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePaginatedQuery creates a paginated query
|
||||
func CreatePaginatedQuery(table string, fields []string, filters []DynamicFilter, sorts []SortField, limit, offset int) DynamicQuery {
|
||||
selectFields := make([]SelectField, len(fields))
|
||||
for i, field := range fields {
|
||||
selectFields[i] = SelectField{Expression: field}
|
||||
}
|
||||
|
||||
return DynamicQuery{
|
||||
From: table,
|
||||
Fields: selectFields,
|
||||
Filters: []FilterGroup{{Filters: filters, LogicOp: "AND"}},
|
||||
Sort: sorts,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInsertData creates insert data from a map
|
||||
func CreateInsertData(data map[string]interface{}) InsertData {
|
||||
columns := make([]string, 0, len(data))
|
||||
values := make([]interface{}, 0, len(data))
|
||||
|
||||
for col, val := range data {
|
||||
columns = append(columns, col)
|
||||
values = append(values, val)
|
||||
}
|
||||
|
||||
return InsertData{
|
||||
Columns: columns,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUpdateData creates update data from a map
|
||||
func CreateUpdateData(data map[string]interface{}) UpdateData {
|
||||
columns := make([]string, 0, len(data))
|
||||
values := make([]interface{}, 0, len(data))
|
||||
|
||||
for col, val := range data {
|
||||
columns = append(columns, col)
|
||||
values = append(values, val)
|
||||
}
|
||||
|
||||
return UpdateData{
|
||||
Columns: columns,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFilter creates a simple filter
|
||||
func CreateFilter(column string, operator FilterOperator, value interface{}) DynamicFilter {
|
||||
return DynamicFilter{
|
||||
Column: column,
|
||||
Operator: operator,
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateEqualFilter creates an equality filter
|
||||
func CreateEqualFilter(column string, value interface{}) DynamicFilter {
|
||||
return CreateFilter(column, OpEqual, value)
|
||||
}
|
||||
|
||||
// CreateLikeFilter creates a LIKE filter
|
||||
func CreateLikeFilter(column string, value string) DynamicFilter {
|
||||
return CreateFilter(column, OpLike, value)
|
||||
}
|
||||
|
||||
// CreateInFilter creates an IN filter
|
||||
func CreateInFilter(column string, values []interface{}) DynamicFilter {
|
||||
return CreateFilter(column, OpIn, values)
|
||||
}
|
||||
|
||||
// CreateBetweenFilter creates a BETWEEN filter
|
||||
func CreateBetweenFilter(column string, min, max interface{}) DynamicFilter {
|
||||
return CreateFilter(column, OpBetween, []interface{}{min, max})
|
||||
}
|
||||
|
||||
// CreateSort creates a sort field
|
||||
func CreateSort(column string, order string) SortField {
|
||||
return SortField{
|
||||
Column: column,
|
||||
Order: order,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAscSort creates an ascending sort
|
||||
func CreateAscSort(column string) SortField {
|
||||
return CreateSort(column, "ASC")
|
||||
}
|
||||
|
||||
// CreateDescSort creates a descending sort
|
||||
func CreateDescSort(column string) SortField {
|
||||
return CreateSort(column, "DESC")
|
||||
}
|
||||
|
||||
// CreateFilterGroup creates a filter group
|
||||
func CreateFilterGroup(filters []DynamicFilter, logicOp string) FilterGroup {
|
||||
return FilterGroup{
|
||||
Filters: filters,
|
||||
LogicOp: logicOp,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAndFilterGroup creates an AND filter group
|
||||
func CreateAndFilterGroup(filters []DynamicFilter) FilterGroup {
|
||||
return CreateFilterGroup(filters, "AND")
|
||||
}
|
||||
|
||||
// CreateOrFilterGroup creates an OR filter group
|
||||
func CreateOrFilterGroup(filters []DynamicFilter) FilterGroup {
|
||||
return CreateFilterGroup(filters, "OR")
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DatabaseDialect defines the interface for database-specific operations
|
||||
type DatabaseDialect interface {
|
||||
// SQL generation methods
|
||||
Placeholder() string
|
||||
EscapeIdentifier(identifier string) string
|
||||
BuildLimitOffset(limit, offset int) string
|
||||
BuildJsonExtract(column, path string) string
|
||||
BuildJsonContains(column, path string) (string, []interface{})
|
||||
BuildJsonExists(column, path string) string
|
||||
BuildArrayContains(column string) string
|
||||
BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error)
|
||||
BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error)
|
||||
BuildArrayLength(column string) string
|
||||
BuildCaseInsensitiveLike(column string) string
|
||||
BuildUpsert(table string, conflictColumns, updateColumns []string) string
|
||||
|
||||
// Type information
|
||||
GetType() DBType
|
||||
}
|
||||
|
||||
// BaseDialect provides common functionality for all dialects
|
||||
type BaseDialect struct {
|
||||
dbType DBType
|
||||
}
|
||||
|
||||
func (d *BaseDialect) GetType() DBType {
|
||||
return d.dbType
|
||||
}
|
||||
|
||||
// PostgreSQLDialect implements DatabaseDialect for PostgreSQL
|
||||
type PostgreSQLDialect struct {
|
||||
BaseDialect
|
||||
}
|
||||
|
||||
func NewPostgreSQLDialect() *PostgreSQLDialect {
|
||||
return &PostgreSQLDialect{
|
||||
BaseDialect: BaseDialect{dbType: DBTypePostgreSQL},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) Placeholder() string {
|
||||
return "$%d"
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) EscapeIdentifier(identifier string) string {
|
||||
parts := strings.Split(identifier, ".")
|
||||
escapedParts := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
// Hindari meng-quote karakter '*' yang digunakan di COUNT(*)
|
||||
if part == "*" {
|
||||
escapedParts[i] = "*"
|
||||
} else if len(part) > 1 && strings.HasPrefix(part, `"`) && strings.HasSuffix(part, `"`) {
|
||||
// Already quoted
|
||||
escapedParts[i] = part
|
||||
} else {
|
||||
escapedParts[i] = `"` + strings.ReplaceAll(part, `"`, `""`) + `"`
|
||||
}
|
||||
}
|
||||
return strings.Join(escapedParts, ".")
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildLimitOffset(limit, offset int) string {
|
||||
if limit > 0 && offset > 0 {
|
||||
return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset)
|
||||
} else if limit > 0 {
|
||||
return fmt.Sprintf("LIMIT %d", limit)
|
||||
} else if offset > 0 {
|
||||
return fmt.Sprintf("OFFSET %d", offset)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildJsonExtract(column, path string) string {
|
||||
return fmt.Sprintf("%s->'%s'", column, path)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildJsonContains(column, path string) (string, []interface{}) {
|
||||
return fmt.Sprintf("%s @> ?", column), []interface{}{}
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildJsonExists(column, path string) string {
|
||||
return fmt.Sprintf("jsonb_path_exists(%s, '%s')", column, path)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildArrayContains(column string) string {
|
||||
return fmt.Sprintf("? = ANY(%s)", column)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildArrayLength(column string) string {
|
||||
return fmt.Sprintf("array_length(%s, 1)", column)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildCaseInsensitiveLike(column string) string {
|
||||
return fmt.Sprintf("%s ILIKE ?", column)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
|
||||
if len(conflictColumns) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
conflictTarget := strings.Join(conflictColumns, ", ")
|
||||
setClause := ""
|
||||
for _, col := range updateColumns {
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += fmt.Sprintf("%s = EXCLUDED.%s", d.EscapeIdentifier(col), d.EscapeIdentifier(col))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ON CONFLICT (%s) DO UPDATE SET %s", conflictTarget, setClause)
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
path := "$"
|
||||
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
|
||||
path = pathOption
|
||||
}
|
||||
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpJsonContains:
|
||||
expr = fmt.Sprintf("%s @> ?", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotContains:
|
||||
expr = fmt.Sprintf("NOT (%s @> ?)", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonExists:
|
||||
expr = fmt.Sprintf("jsonb_path_exists(%s, '%s')", column, path)
|
||||
case OpJsonNotExists:
|
||||
expr = fmt.Sprintf("NOT jsonb_path_exists(%s, '%s')", column, path)
|
||||
case OpJsonEqual:
|
||||
expr = fmt.Sprintf("%s->>'%s' = ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotEqual:
|
||||
expr = fmt.Sprintf("%s->>'%s' <> ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported JSON operator for PostgreSQL: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
func (d *PostgreSQLDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpArrayContains:
|
||||
expr = fmt.Sprintf("? = ANY(%s)", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayNotContains:
|
||||
expr = fmt.Sprintf("? <> ALL(%s)", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayLength:
|
||||
if lengthOption, ok := filter.Options["length"].(int); ok {
|
||||
expr = fmt.Sprintf("array_length(%s, 1) = ?", column)
|
||||
args = append(args, lengthOption)
|
||||
} else {
|
||||
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported array operator for PostgreSQL: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
// MySQLDialect implements DatabaseDialect for MySQL
|
||||
type MySQLDialect struct {
|
||||
BaseDialect
|
||||
}
|
||||
|
||||
func NewMySQLDialect() *MySQLDialect {
|
||||
return &MySQLDialect{
|
||||
BaseDialect: BaseDialect{dbType: DBTypeMySQL},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) Placeholder() string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) EscapeIdentifier(identifier string) string {
|
||||
parts := strings.Split(identifier, ".")
|
||||
escapedParts := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "*" {
|
||||
escapedParts[i] = "*"
|
||||
} else if len(part) > 1 && strings.HasPrefix(part, "`") && strings.HasSuffix(part, "`") {
|
||||
// Already quoted
|
||||
escapedParts[i] = part
|
||||
} else {
|
||||
escapedParts[i] = "`" + strings.ReplaceAll(part, "`", "``") + "`"
|
||||
}
|
||||
}
|
||||
return strings.Join(escapedParts, ".")
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildLimitOffset(limit, offset int) string {
|
||||
if limit > 0 && offset > 0 {
|
||||
return fmt.Sprintf("LIMIT %d, %d", offset, limit)
|
||||
} else if limit > 0 {
|
||||
return fmt.Sprintf("LIMIT %d", limit)
|
||||
} else if offset > 0 {
|
||||
return fmt.Sprintf("LIMIT %d, 18446744073709551615", offset)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildJsonExtract(column, path string) string {
|
||||
return fmt.Sprintf("JSON_EXTRACT(%s, '$.%s')", column, path)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildJsonContains(column, path string) (string, []interface{}) {
|
||||
return fmt.Sprintf("JSON_CONTAINS(%s, ?, '$.%s')", column, path), []interface{}{}
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildJsonExists(column, path string) string {
|
||||
return fmt.Sprintf("JSON_CONTAINS_PATH(%s, 'one', '$.%s')", column, path)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildArrayContains(column string) string {
|
||||
return fmt.Sprintf("JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildArrayLength(column string) string {
|
||||
return fmt.Sprintf("JSON_LENGTH(%s)", column)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildCaseInsensitiveLike(column string) string {
|
||||
return fmt.Sprintf("LOWER(%s) LIKE LOWER(?)", column)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
|
||||
if len(updateColumns) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
setClause := ""
|
||||
for _, col := range updateColumns {
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += fmt.Sprintf("%s = VALUES(%s)", d.EscapeIdentifier(col), d.EscapeIdentifier(col))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ON DUPLICATE KEY UPDATE %s", setClause)
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
path := "$"
|
||||
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
|
||||
path = pathOption
|
||||
}
|
||||
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpJsonContains:
|
||||
expr = fmt.Sprintf("JSON_CONTAINS(%s, ?, '%s')", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotContains:
|
||||
expr = fmt.Sprintf("NOT JSON_CONTAINS(%s, ?, '%s')", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonExists:
|
||||
expr = fmt.Sprintf("JSON_CONTAINS_PATH(%s, 'one', '%s')", column, path)
|
||||
case OpJsonNotExists:
|
||||
expr = fmt.Sprintf("NOT JSON_CONTAINS_PATH(%s, 'one', '%s')", column, path)
|
||||
case OpJsonEqual:
|
||||
expr = fmt.Sprintf("JSON_EXTRACT(%s, '%s') = ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotEqual:
|
||||
expr = fmt.Sprintf("JSON_EXTRACT(%s, '%s') <> ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported JSON operator for MySQL: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
func (d *MySQLDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpArrayContains:
|
||||
expr = fmt.Sprintf("JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayNotContains:
|
||||
expr = fmt.Sprintf("NOT JSON_CONTAINS(%s, JSON_QUOTE(?))", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayLength:
|
||||
if lengthOption, ok := filter.Options["length"].(int); ok {
|
||||
expr = fmt.Sprintf("JSON_LENGTH(%s) = ?", column)
|
||||
args = append(args, lengthOption)
|
||||
} else {
|
||||
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported array operator for MySQL: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
// SQLiteDialect implements DatabaseDialect for SQLite
|
||||
type SQLiteDialect struct {
|
||||
BaseDialect
|
||||
}
|
||||
|
||||
func NewSQLiteDialect() *SQLiteDialect {
|
||||
return &SQLiteDialect{
|
||||
BaseDialect: BaseDialect{dbType: DBTypeSQLite},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) Placeholder() string {
|
||||
return "?"
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) EscapeIdentifier(identifier string) string {
|
||||
parts := strings.Split(identifier, ".")
|
||||
escapedParts := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "*" {
|
||||
escapedParts[i] = "*"
|
||||
} else if len(part) > 1 && strings.HasPrefix(part, `"`) && strings.HasSuffix(part, `"`) {
|
||||
// Already quoted
|
||||
escapedParts[i] = part
|
||||
} else {
|
||||
escapedParts[i] = `"` + strings.ReplaceAll(part, `"`, `""`) + `"`
|
||||
}
|
||||
}
|
||||
return strings.Join(escapedParts, ".")
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildLimitOffset(limit, offset int) string {
|
||||
if limit > 0 && offset > 0 {
|
||||
return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset)
|
||||
} else if limit > 0 {
|
||||
return fmt.Sprintf("LIMIT %d", limit)
|
||||
} else if offset > 0 {
|
||||
return fmt.Sprintf("OFFSET %d", offset)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildJsonExtract(column, path string) string {
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s')", column, path)
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildJsonContains(column, path string) (string, []interface{}) {
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s') = ?", column, path), []interface{}{}
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildJsonExists(column, path string) string {
|
||||
return fmt.Sprintf("json_extract(%s, '$.%s') IS NOT NULL", column, path)
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildArrayContains(column string) string {
|
||||
return fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildArrayLength(column string) string {
|
||||
return fmt.Sprintf("json_array_length(%s)", column)
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildCaseInsensitiveLike(column string) string {
|
||||
return fmt.Sprintf("%s LIKE ?", column)
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
|
||||
// SQLite doesn't have ON CONFLICT for INSERT, use INSERT OR REPLACE or separate logic
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
path := "$"
|
||||
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
|
||||
path = pathOption
|
||||
}
|
||||
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpJsonContains:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') = ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotContains:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') <> ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonExists:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') IS NOT NULL", column, path)
|
||||
case OpJsonNotExists:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') IS NULL", column, path)
|
||||
case OpJsonEqual:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') = ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotEqual:
|
||||
expr = fmt.Sprintf("json_extract(%s, '%s') <> ?", column, path)
|
||||
args = append(args, filter.Value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported JSON operator for SQLite: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpArrayContains:
|
||||
expr = fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayNotContains:
|
||||
expr = fmt.Sprintf("NOT EXISTS (SELECT 1 FROM json_each(%s) WHERE json_each.value = ?)", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayLength:
|
||||
if lengthOption, ok := filter.Options["length"].(int); ok {
|
||||
expr = fmt.Sprintf("json_array_length(%s) = ?", column)
|
||||
args = append(args, lengthOption)
|
||||
} else {
|
||||
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported array operator for SQLite: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
// SQLServerDialect implements DatabaseDialect for SQL Server
|
||||
type SQLServerDialect struct {
|
||||
BaseDialect
|
||||
}
|
||||
|
||||
func NewSQLServerDialect() *SQLServerDialect {
|
||||
return &SQLServerDialect{
|
||||
BaseDialect: BaseDialect{dbType: DBTypeSQLServer},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) Placeholder() string {
|
||||
return "@p%d"
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) EscapeIdentifier(identifier string) string {
|
||||
parts := strings.Split(identifier, ".")
|
||||
escapedParts := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "*" {
|
||||
escapedParts[i] = "*"
|
||||
} else if len(part) > 1 && strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") {
|
||||
// Already quoted
|
||||
escapedParts[i] = part
|
||||
} else {
|
||||
escapedParts[i] = "[" + strings.ReplaceAll(part, "]", "]]") + "]"
|
||||
}
|
||||
}
|
||||
return strings.Join(escapedParts, ".")
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildLimitOffset(limit, offset int) string {
|
||||
if limit > 0 && offset > 0 {
|
||||
return fmt.Sprintf("OFFSET %d ROWS FETCH NEXT %d ROWS ONLY", offset, limit)
|
||||
} else if limit > 0 {
|
||||
return fmt.Sprintf("FETCH NEXT %d ROWS ONLY", limit)
|
||||
} else if offset > 0 {
|
||||
return fmt.Sprintf("OFFSET %d ROWS", offset)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildJsonExtract(column, path string) string {
|
||||
return fmt.Sprintf("JSON_VALUE(%s, '%s')", column, d.escapeSqlServerJsonPath(path))
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildJsonContains(column, path string) (string, []interface{}) {
|
||||
return fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path)), []interface{}{}
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildJsonExists(column, path string) string {
|
||||
return fmt.Sprintf("JSON_VALUE(%s, '%s') IS NOT NULL", column, d.escapeSqlServerJsonPath(path))
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildArrayContains(column string) string {
|
||||
return fmt.Sprintf("? IN (SELECT value FROM OPENJSON(%s))", column)
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildArrayLength(column string) string {
|
||||
return fmt.Sprintf("(SELECT COUNT(*) FROM OPENJSON(%s))", column)
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildCaseInsensitiveLike(column string) string {
|
||||
return fmt.Sprintf("LOWER(%s) LIKE LOWER(?)", column)
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildUpsert(table string, conflictColumns, updateColumns []string) string {
|
||||
// SQL Server uses MERGE for upsert operations
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) escapeSqlServerJsonPath(path string) string {
|
||||
// Convert $.path to proper SQL Server JSON path format
|
||||
if strings.HasPrefix(path, "$.") {
|
||||
return path
|
||||
}
|
||||
return "$." + path
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildJsonFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
path := "$"
|
||||
if pathOption, ok := filter.Options["path"].(string); ok && pathOption != "" {
|
||||
path = pathOption
|
||||
}
|
||||
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpJsonContains:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path))
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotContains:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') <> ?", column, d.escapeSqlServerJsonPath(path))
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonExists:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') IS NOT NULL", column, d.escapeSqlServerJsonPath(path))
|
||||
case OpJsonNotExists:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') IS NULL", column, d.escapeSqlServerJsonPath(path))
|
||||
case OpJsonEqual:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') = ?", column, d.escapeSqlServerJsonPath(path))
|
||||
args = append(args, filter.Value)
|
||||
case OpJsonNotEqual:
|
||||
expr = fmt.Sprintf("JSON_VALUE(%s, '%s') <> ?", column, d.escapeSqlServerJsonPath(path))
|
||||
args = append(args, filter.Value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported JSON operator for SQL Server: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
func (d *SQLServerDialect) BuildArrayFilter(column string, filter DynamicFilter) (string, []interface{}, error) {
|
||||
var expr string
|
||||
var args []interface{}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpArrayContains:
|
||||
expr = fmt.Sprintf("? IN (SELECT value FROM OPENJSON(%s))", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayNotContains:
|
||||
expr = fmt.Sprintf("? NOT IN (SELECT value FROM OPENJSON(%s))", column)
|
||||
args = append(args, filter.Value)
|
||||
case OpArrayLength:
|
||||
if lengthOption, ok := filter.Options["length"].(int); ok {
|
||||
expr = fmt.Sprintf("(SELECT COUNT(*) FROM OPENJSON(%s)) = ?", column)
|
||||
args = append(args, lengthOption)
|
||||
} else {
|
||||
return "", nil, fmt.Errorf("array_length operator requires 'length' option")
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported array operator for SQL Server: %s", filter.Operator)
|
||||
}
|
||||
return expr, args, nil
|
||||
}
|
||||
|
||||
// GetDialect returns the appropriate dialect for the given database type
|
||||
func GetDialect(dbType DBType) DatabaseDialect {
|
||||
switch dbType {
|
||||
case DBTypePostgreSQL:
|
||||
return NewPostgreSQLDialect()
|
||||
case DBTypeMySQL:
|
||||
return NewMySQLDialect()
|
||||
case DBTypeSQLite:
|
||||
return NewSQLiteDialect()
|
||||
case DBTypeSQLServer:
|
||||
return NewSQLServerDialect()
|
||||
default:
|
||||
return NewPostgreSQLDialect() // Default fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPostgreSQLDialect_EscapeIdentifier(t *testing.T) {
|
||||
dialect := NewPostgreSQLDialect()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple lowercase",
|
||||
input: "users",
|
||||
expected: `"users"`,
|
||||
},
|
||||
{
|
||||
name: "PascalCase with underscore",
|
||||
input: "Nama_device",
|
||||
expected: `"Nama_device"`,
|
||||
},
|
||||
{
|
||||
name: "PascalCase",
|
||||
input: "Tahun",
|
||||
expected: `"Tahun"`,
|
||||
},
|
||||
{
|
||||
name: "Schema and table",
|
||||
input: "role_access.rol_permission",
|
||||
expected: `"role_access"."rol_permission"`,
|
||||
},
|
||||
{
|
||||
name: "Wildcard should not be escaped",
|
||||
input: "*",
|
||||
expected: `*`,
|
||||
},
|
||||
{
|
||||
name: "Identifier with quotes inside",
|
||||
input: `user"s`,
|
||||
expected: `"user""s"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := dialect.EscapeIdentifier(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDialect_EscapeIdentifier(t *testing.T) {
|
||||
dialect := NewMySQLDialect()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple lowercase",
|
||||
input: "users",
|
||||
expected: "`users`",
|
||||
},
|
||||
{
|
||||
name: "PascalCase with underscore",
|
||||
input: "Nama_device",
|
||||
expected: "`Nama_device`",
|
||||
},
|
||||
{
|
||||
name: "PascalCase",
|
||||
input: "Tahun",
|
||||
expected: "`Tahun`",
|
||||
},
|
||||
{
|
||||
name: "Schema and table",
|
||||
input: "role_access.rol_permission",
|
||||
expected: "`role_access`.`rol_permission`",
|
||||
},
|
||||
{
|
||||
name: "Wildcard should not be escaped",
|
||||
input: "*",
|
||||
expected: `*`,
|
||||
},
|
||||
{
|
||||
name: "Identifier with backticks inside",
|
||||
input: "user`s",
|
||||
expected: "`user``s`",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := dialect.EscapeIdentifier(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLServerDialect_EscapeIdentifier(t *testing.T) {
|
||||
dialect := NewSQLServerDialect()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple lowercase",
|
||||
input: "users",
|
||||
expected: "[users]",
|
||||
},
|
||||
{
|
||||
name: "PascalCase with underscore",
|
||||
input: "Nama_device",
|
||||
expected: "[Nama_device]",
|
||||
},
|
||||
{
|
||||
name: "PascalCase",
|
||||
input: "Tahun",
|
||||
expected: "[Tahun]",
|
||||
},
|
||||
{
|
||||
name: "Schema and table",
|
||||
input: "role_access.rol_permission",
|
||||
expected: "[role_access].[rol_permission]",
|
||||
},
|
||||
{
|
||||
name: "Wildcard should not be escaped",
|
||||
input: "*",
|
||||
expected: `*`,
|
||||
},
|
||||
{
|
||||
name: "Identifier with brackets inside",
|
||||
input: "user]s",
|
||||
expected: "[user]]s]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := dialect.EscapeIdentifier(tc.input)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteDialect_EscapeIdentifier(t *testing.T) {
|
||||
dialect := NewSQLiteDialect()
|
||||
|
||||
// SQLite uses the same quoting as PostgreSQL
|
||||
t.Run("PascalCase with underscore", func(t *testing.T) {
|
||||
result := dialect.EscapeIdentifier("Nama_device")
|
||||
assert.Equal(t, `"Nama_device"`, result)
|
||||
})
|
||||
|
||||
t.Run("Schema and table", func(t *testing.T) {
|
||||
result := dialect.EscapeIdentifier("role_access.rol_permission")
|
||||
assert.Equal(t, `"role_access"."rol_permission"`, result)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getDefaultPort returns default port for database type
|
||||
func getDefaultPort(dbType string) int {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return 5432
|
||||
case "mysql":
|
||||
return 3306
|
||||
case "sqlserver":
|
||||
return 1433
|
||||
case "mongodb":
|
||||
return 27017
|
||||
case "sqlite":
|
||||
return 0 // SQLite doesn't use port
|
||||
default:
|
||||
return 5432
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultSchema returns default schema for database type
|
||||
func getDefaultSchema(dbType string) string {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return "public"
|
||||
case "mysql":
|
||||
return ""
|
||||
case "sqlserver":
|
||||
return "dbo"
|
||||
case "mongodb":
|
||||
return ""
|
||||
case "sqlite":
|
||||
return ""
|
||||
default:
|
||||
return "public"
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultSSLMode returns default SSL mode for database type
|
||||
func getDefaultSSLMode(dbType string) string {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return "disable"
|
||||
case "mysql":
|
||||
return "false"
|
||||
case "sqlserver":
|
||||
return "false"
|
||||
case "mongodb":
|
||||
return "false"
|
||||
case "sqlite":
|
||||
return ""
|
||||
default:
|
||||
return "disable"
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultMaxOpenConns returns default max open connections for database type
|
||||
func getDefaultMaxOpenConns(dbType string) int {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return 25
|
||||
case "mysql":
|
||||
return 25
|
||||
case "sqlserver":
|
||||
return 25
|
||||
case "mongodb":
|
||||
return 100
|
||||
case "sqlite":
|
||||
return 1 // SQLite only supports one writer at a time
|
||||
default:
|
||||
return 25
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultMaxIdleConns returns default max idle connections for database type
|
||||
func getDefaultMaxIdleConns(dbType string) int {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return 25
|
||||
case "mysql":
|
||||
return 25
|
||||
case "sqlserver":
|
||||
return 25
|
||||
case "mongodb":
|
||||
return 10
|
||||
case "sqlite":
|
||||
return 1 // SQLite only supports one writer at a time
|
||||
default:
|
||||
return 25
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultConnMaxLifetime returns default connection max lifetime for database type
|
||||
func getDefaultConnMaxLifetime(dbType string) string {
|
||||
switch dbType {
|
||||
case "postgres":
|
||||
return "5m"
|
||||
case "mysql":
|
||||
return "5m"
|
||||
case "sqlserver":
|
||||
return "5m"
|
||||
case "mongodb":
|
||||
return "30m"
|
||||
case "sqlite":
|
||||
return "5m"
|
||||
default:
|
||||
return "5m"
|
||||
}
|
||||
}
|
||||
|
||||
// getEnvFromMap gets value from map with default
|
||||
func getEnvFromMap(config map[string]string, key, defaultValue string) string {
|
||||
if value, exists := config[key]; exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsIntFromMap gets int value from map with default
|
||||
func getEnvAsIntFromMap(config map[string]string, key string, defaultValue int) int {
|
||||
if value, exists := config[key]; exists {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsBoolFromMap gets bool value from map with default
|
||||
func getEnvAsBoolFromMap(config map[string]string, key string, defaultValue bool) bool {
|
||||
if value, exists := config[key]; exists {
|
||||
if boolValue, err := strconv.ParseBool(value); err == nil {
|
||||
return boolValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// parseSchemes parses comma-separated schemes string into a slice
|
||||
func parseSchemes(schemesStr string) []string {
|
||||
if schemesStr == "" {
|
||||
return []string{"http"}
|
||||
}
|
||||
|
||||
schemes := strings.Split(schemesStr, ",")
|
||||
for i, scheme := range schemes {
|
||||
schemes[i] = strings.TrimSpace(scheme)
|
||||
}
|
||||
return schemes
|
||||
}
|
||||
|
||||
// parseStaticTokens parses comma-separated static tokens string into a slice
|
||||
func parseStaticTokens(tokensStr string) []string {
|
||||
if tokensStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
tokens := strings.Split(tokensStr, ",")
|
||||
var result []string
|
||||
|
||||
for _, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token != "" {
|
||||
result = append(result, token)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseOrigins parses comma-separated origins string into a slice
|
||||
func parseOrigins(originsStr string) []string {
|
||||
if originsStr == "" {
|
||||
return []string{"http://localhost:8080"} // Default for development
|
||||
}
|
||||
origins := strings.Split(originsStr, ",")
|
||||
for i, origin := range origins {
|
||||
origins[i] = strings.TrimSpace(origin)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// parseDuration parses duration string
|
||||
func parseDuration(durationStr string) time.Duration {
|
||||
if duration, err := time.ParseDuration(durationStr); err == nil {
|
||||
return duration
|
||||
}
|
||||
return 5 * time.Minute
|
||||
}
|
||||
|
||||
// getEnv gets environment variable with default
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsInt gets environment variable as int with default
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
valueStr := getEnv(key, "")
|
||||
if value, err := strconv.Atoi(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsBool gets environment variable as bool with default
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
valueStr := getEnv(key, "")
|
||||
if value, err := strconv.ParseBool(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// MongoQueryBuilderImpl implements MongoQueryBuilder interface
|
||||
type MongoQueryBuilderImpl struct {
|
||||
allowedFields map[string]bool // Security: only allow specified fields
|
||||
allowedCollections map[string]bool // Security: only allow specified collections
|
||||
// Security settings
|
||||
enableSecurityChecks bool
|
||||
maxAllowedDocs int
|
||||
// Query logging
|
||||
enableQueryLogging bool
|
||||
// Connection timeout settings
|
||||
queryTimeout int
|
||||
}
|
||||
|
||||
// NewMongoQueryBuilder creates a new MongoDB query builder instance
|
||||
func NewMongoQueryBuilder() *MongoQueryBuilderImpl {
|
||||
return &MongoQueryBuilderImpl{
|
||||
allowedFields: make(map[string]bool),
|
||||
allowedCollections: make(map[string]bool),
|
||||
enableSecurityChecks: true,
|
||||
maxAllowedDocs: 10000,
|
||||
enableQueryLogging: true,
|
||||
queryTimeout: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecurityOptions configures security settings
|
||||
func (mqb *MongoQueryBuilderImpl) SetSecurityOptions(enableChecks bool, maxDocs int) MongoQueryBuilder {
|
||||
mqb.enableSecurityChecks = enableChecks
|
||||
mqb.maxAllowedDocs = maxDocs
|
||||
return mqb
|
||||
}
|
||||
|
||||
// SetAllowedFields sets the list of allowed fields for security
|
||||
func (mqb *MongoQueryBuilderImpl) SetAllowedFields(fields []string) MongoQueryBuilder {
|
||||
mqb.allowedFields = make(map[string]bool)
|
||||
for _, field := range fields {
|
||||
mqb.allowedFields[field] = true
|
||||
}
|
||||
return mqb
|
||||
}
|
||||
|
||||
// SetAllowedCollections sets the list of allowed collections for security
|
||||
func (mqb *MongoQueryBuilderImpl) SetAllowedCollections(collections []string) MongoQueryBuilder {
|
||||
mqb.allowedCollections = make(map[string]bool)
|
||||
for _, collection := range collections {
|
||||
mqb.allowedCollections[collection] = true
|
||||
}
|
||||
return mqb
|
||||
}
|
||||
|
||||
// SetQueryLogging enables or disables query logging
|
||||
func (mqb *MongoQueryBuilderImpl) SetQueryLogging(enable bool) MongoQueryBuilder {
|
||||
mqb.enableQueryLogging = enable
|
||||
return mqb
|
||||
}
|
||||
|
||||
// SetQueryTimeout sets the default query timeout
|
||||
func (mqb *MongoQueryBuilderImpl) SetQueryTimeout(timeout time.Duration) MongoQueryBuilder {
|
||||
mqb.queryTimeout = int(timeout.Seconds())
|
||||
return mqb
|
||||
}
|
||||
|
||||
// BuildFindQuery builds a MongoDB find query from DynamicQuery
|
||||
func (mqb *MongoQueryBuilderImpl) BuildFindQuery(query DynamicQuery) (interface{}, interface{}, error) {
|
||||
filter := bson.M{}
|
||||
findOptions := options.Find()
|
||||
|
||||
// Security check for limit
|
||||
if mqb.enableSecurityChecks && query.Limit > mqb.maxAllowedDocs {
|
||||
return nil, nil, fmt.Errorf("requested limit %d exceeds maximum allowed %d", query.Limit, mqb.maxAllowedDocs)
|
||||
}
|
||||
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
|
||||
return nil, nil, fmt.Errorf("disallowed collection: %s", query.From)
|
||||
}
|
||||
|
||||
// Build filter from DynamicQuery filters
|
||||
if len(query.Filters) > 0 {
|
||||
mongoFilter, err := mqb.buildFilter(query.Filters)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
filter = mongoFilter
|
||||
}
|
||||
|
||||
// Set projection from fields
|
||||
if len(query.Fields) > 0 {
|
||||
projection := bson.M{}
|
||||
for _, field := range query.Fields {
|
||||
if field.Expression == "*" {
|
||||
// Include all fields
|
||||
continue
|
||||
}
|
||||
fieldName := field.Expression
|
||||
if field.Alias != "" {
|
||||
fieldName = field.Alias
|
||||
}
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
|
||||
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
|
||||
}
|
||||
projection[fieldName] = 1
|
||||
}
|
||||
if len(projection) > 0 {
|
||||
findOptions.SetProjection(projection)
|
||||
}
|
||||
}
|
||||
|
||||
// Set sort
|
||||
if len(query.Sort) > 0 {
|
||||
sort := bson.D{}
|
||||
for _, sortField := range query.Sort {
|
||||
fieldName := sortField.Column
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
|
||||
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
|
||||
}
|
||||
order := 1 // ASC
|
||||
if strings.ToUpper(sortField.Order) == "DESC" {
|
||||
order = -1 // DESC
|
||||
}
|
||||
sort = append(sort, bson.E{Key: fieldName, Value: order})
|
||||
}
|
||||
findOptions.SetSort(sort)
|
||||
}
|
||||
|
||||
// Set limit and offset
|
||||
if query.Limit > 0 {
|
||||
findOptions.SetLimit(int64(query.Limit))
|
||||
}
|
||||
if query.Offset > 0 {
|
||||
findOptions.SetSkip(int64(query.Offset))
|
||||
}
|
||||
|
||||
return filter, findOptions, nil
|
||||
}
|
||||
|
||||
// BuildAggregateQuery builds a MongoDB aggregation pipeline from DynamicQuery
|
||||
func (mqb *MongoQueryBuilderImpl) BuildAggregateQuery(query DynamicQuery) (interface{}, error) {
|
||||
pipeline := []bson.D{}
|
||||
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
|
||||
return nil, fmt.Errorf("disallowed collection: %s", query.From)
|
||||
}
|
||||
|
||||
// Handle CTEs as stages in the pipeline
|
||||
if len(query.CTEs) > 0 {
|
||||
for _, cte := range query.CTEs {
|
||||
// Security check for CTE collection
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[cte.Query.From] {
|
||||
return nil, fmt.Errorf("disallowed collection in CTE: %s", cte.Query.From)
|
||||
}
|
||||
|
||||
subPipeline, err := mqb.BuildAggregateQuery(cte.Query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build CTE '%s': %w", cte.Name, err)
|
||||
}
|
||||
// Add $lookup stage for joins
|
||||
if len(cte.Query.Joins) > 0 {
|
||||
for _, join := range cte.Query.Joins {
|
||||
// Security check for joined collection
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[join.Table] {
|
||||
return nil, fmt.Errorf("disallowed collection in join: %s", join.Table)
|
||||
}
|
||||
|
||||
lookupStage := bson.D{
|
||||
{Key: "$lookup", Value: bson.D{
|
||||
{Key: "from", Value: join.Table},
|
||||
{Key: "localField", Value: join.Alias},
|
||||
{Key: "foreignField", Value: "_id"},
|
||||
{Key: "as", Value: join.Alias},
|
||||
}},
|
||||
}
|
||||
pipeline = append(pipeline, lookupStage)
|
||||
}
|
||||
}
|
||||
// Add the sub-pipeline
|
||||
if subPipelineSlice, ok := subPipeline.([]bson.D); ok {
|
||||
pipeline = append(pipeline, subPipelineSlice...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match stage for filters
|
||||
if len(query.Filters) > 0 {
|
||||
filter, err := mqb.buildFilter(query.Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pipeline = append(pipeline, bson.D{{Key: "$match", Value: filter}})
|
||||
}
|
||||
|
||||
// Group stage for GROUP BY
|
||||
if len(query.GroupBy) > 0 {
|
||||
groupID := bson.D{}
|
||||
for _, field := range query.GroupBy {
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", field)
|
||||
}
|
||||
groupID = append(groupID, bson.E{Key: field, Value: "$" + field})
|
||||
}
|
||||
|
||||
groupStage := bson.D{
|
||||
{Key: "$group", Value: bson.D{
|
||||
{Key: "_id", Value: groupID},
|
||||
}},
|
||||
}
|
||||
|
||||
// Add any aggregations from fields
|
||||
for _, field := range query.Fields {
|
||||
if strings.Contains(field.Expression, "(") && strings.Contains(field.Expression, ")") {
|
||||
// This is an aggregation function
|
||||
funcName := strings.Split(field.Expression, "(")[0]
|
||||
funcField := strings.TrimSuffix(strings.Split(field.Expression, "(")[1], ")")
|
||||
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[funcField] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", funcField)
|
||||
}
|
||||
|
||||
switch strings.ToLower(funcName) {
|
||||
case "count":
|
||||
groupStage = append(groupStage, bson.E{
|
||||
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: 1}},
|
||||
})
|
||||
case "sum":
|
||||
groupStage = append(groupStage, bson.E{
|
||||
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: "$" + funcField}},
|
||||
})
|
||||
case "avg":
|
||||
groupStage = append(groupStage, bson.E{
|
||||
Key: field.Alias, Value: bson.D{{Key: "$avg", Value: "$" + funcField}},
|
||||
})
|
||||
case "min":
|
||||
groupStage = append(groupStage, bson.E{
|
||||
Key: field.Alias, Value: bson.D{{Key: "$min", Value: "$" + funcField}},
|
||||
})
|
||||
case "max":
|
||||
groupStage = append(groupStage, bson.E{
|
||||
Key: field.Alias, Value: bson.D{{Key: "$max", Value: "$" + funcField}},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pipeline = append(pipeline, groupStage)
|
||||
}
|
||||
|
||||
// Sort stage
|
||||
if len(query.Sort) > 0 {
|
||||
sort := bson.D{}
|
||||
for _, sortField := range query.Sort {
|
||||
fieldName := sortField.Column
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", fieldName)
|
||||
}
|
||||
order := 1 // ASC
|
||||
if strings.ToUpper(sortField.Order) == "DESC" {
|
||||
order = -1 // DESC
|
||||
}
|
||||
sort = append(sort, bson.E{Key: fieldName, Value: order})
|
||||
}
|
||||
pipeline = append(pipeline, bson.D{{Key: "$sort", Value: sort}})
|
||||
}
|
||||
|
||||
// Skip and limit stages
|
||||
if query.Offset > 0 {
|
||||
pipeline = append(pipeline, bson.D{{Key: "$skip", Value: query.Offset}})
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
pipeline = append(pipeline, bson.D{{Key: "$limit", Value: query.Limit}})
|
||||
}
|
||||
|
||||
return pipeline, nil
|
||||
}
|
||||
|
||||
// buildFilter builds a MongoDB filter from FilterGroups
|
||||
func (mqb *MongoQueryBuilderImpl) buildFilter(filterGroups []FilterGroup) (bson.M, error) {
|
||||
if len(filterGroups) == 0 {
|
||||
return bson.M{}, nil
|
||||
}
|
||||
|
||||
var result bson.M
|
||||
var err error
|
||||
|
||||
for i, group := range filterGroups {
|
||||
if len(group.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
groupFilter, err := mqb.buildFilterGroup(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
result = groupFilter
|
||||
} else {
|
||||
logicOp := "$and"
|
||||
if group.LogicOp != "" {
|
||||
switch strings.ToUpper(group.LogicOp) {
|
||||
case "OR":
|
||||
logicOp = "$or"
|
||||
}
|
||||
}
|
||||
result = bson.M{logicOp: []bson.M{result, groupFilter}}
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// buildFilterGroup builds a filter for a single filter group
|
||||
func (mqb *MongoQueryBuilderImpl) buildFilterGroup(group FilterGroup) (bson.M, error) {
|
||||
var filters []bson.M
|
||||
logicOp := "$and"
|
||||
if group.LogicOp != "" {
|
||||
switch strings.ToUpper(group.LogicOp) {
|
||||
case "OR":
|
||||
logicOp = "$or"
|
||||
}
|
||||
}
|
||||
|
||||
for _, filter := range group.Filters {
|
||||
fieldFilter, err := mqb.buildFilterCondition(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters = append(filters, fieldFilter)
|
||||
}
|
||||
|
||||
if len(filters) == 1 {
|
||||
return filters[0], nil
|
||||
}
|
||||
return bson.M{logicOp: filters}, nil
|
||||
}
|
||||
|
||||
// buildFilterCondition builds a single filter condition for MongoDB
|
||||
func (mqb *MongoQueryBuilderImpl) buildFilterCondition(filter DynamicFilter) (bson.M, error) {
|
||||
field := filter.Column
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", field)
|
||||
}
|
||||
|
||||
switch filter.Operator {
|
||||
case OpEqual:
|
||||
return bson.M{field: filter.Value}, nil
|
||||
case OpNotEqual:
|
||||
return bson.M{field: bson.M{"$ne": filter.Value}}, nil
|
||||
case OpIn:
|
||||
values := mqb.parseArrayValue(filter.Value)
|
||||
return bson.M{field: bson.M{"$in": values}}, nil
|
||||
case OpNotIn:
|
||||
values := mqb.parseArrayValue(filter.Value)
|
||||
return bson.M{field: bson.M{"$nin": values}}, nil
|
||||
case OpGreaterThan:
|
||||
return bson.M{field: bson.M{"$gt": filter.Value}}, nil
|
||||
case OpGreaterThanEqual:
|
||||
return bson.M{field: bson.M{"$gte": filter.Value}}, nil
|
||||
case OpLessThan:
|
||||
return bson.M{field: bson.M{"$lt": filter.Value}}, nil
|
||||
case OpLessThanEqual:
|
||||
return bson.M{field: bson.M{"$lte": filter.Value}}, nil
|
||||
case OpLike:
|
||||
// Convert SQL LIKE to MongoDB regex
|
||||
pattern := filter.Value.(string)
|
||||
pattern = strings.ReplaceAll(pattern, "%", ".*")
|
||||
pattern = strings.ReplaceAll(pattern, "_", ".")
|
||||
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
|
||||
case OpILike:
|
||||
// Case-insensitive like
|
||||
pattern := filter.Value.(string)
|
||||
pattern = strings.ReplaceAll(pattern, "%", ".*")
|
||||
pattern = strings.ReplaceAll(pattern, "_", ".")
|
||||
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
|
||||
case OpContains:
|
||||
// Contains substring
|
||||
pattern := filter.Value.(string)
|
||||
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
|
||||
case OpNotContains:
|
||||
// Does not contain substring
|
||||
pattern := filter.Value.(string)
|
||||
return bson.M{field: bson.M{"$not": bson.M{"$regex": pattern, "$options": "i"}}}, nil
|
||||
case OpStartsWith:
|
||||
// Starts with
|
||||
pattern := filter.Value.(string)
|
||||
return bson.M{field: bson.M{"$regex": "^" + pattern, "$options": "i"}}, nil
|
||||
case OpEndsWith:
|
||||
// Ends with
|
||||
pattern := filter.Value.(string)
|
||||
return bson.M{field: bson.M{"$regex": pattern + "$", "$options": "i"}}, nil
|
||||
case OpNull:
|
||||
return bson.M{field: bson.M{"$exists": false}}, nil
|
||||
case OpNotNull:
|
||||
return bson.M{field: bson.M{"$exists": true}}, nil
|
||||
case OpJsonContains:
|
||||
// JSON contains
|
||||
return bson.M{field: bson.M{"$elemMatch": filter.Value}}, nil
|
||||
case OpJsonNotContains:
|
||||
// JSON does not contain
|
||||
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": filter.Value}}}, nil
|
||||
case OpJsonExists:
|
||||
// JSON path exists
|
||||
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": true}}, nil
|
||||
case OpJsonNotExists:
|
||||
// JSON path does not exist
|
||||
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": false}}, nil
|
||||
case OpArrayContains:
|
||||
// Array contains
|
||||
return bson.M{field: bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}, nil
|
||||
case OpArrayNotContains:
|
||||
// Array does not contain
|
||||
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}}, nil
|
||||
case OpArrayLength:
|
||||
// Array length
|
||||
if lengthOption, ok := filter.Options["length"].(int); ok {
|
||||
return bson.M{field: bson.M{"$size": lengthOption}}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("array_length operator requires 'length' option")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
|
||||
}
|
||||
}
|
||||
|
||||
// parseArrayValue parses an array value for MongoDB
|
||||
func (mqb *MongoQueryBuilderImpl) parseArrayValue(value interface{}) []interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if reflect.TypeOf(value).Kind() == reflect.Slice {
|
||||
v := reflect.ValueOf(value)
|
||||
result := make([]interface{}, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
if strings.Contains(str, ",") {
|
||||
parts := strings.Split(str, ",")
|
||||
result := make([]interface{}, len(parts))
|
||||
for i, part := range parts {
|
||||
result[i] = strings.TrimSpace(part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return []interface{}{str}
|
||||
}
|
||||
return []interface{}{value}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// ExecuteFind executes a MongoDB find query
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteFind(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
filter, findOptions, err := mqb.BuildFindQuery(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
cursor, err := collection.Find(ctx, filter.(bson.M), findOptions.(*options.FindOptions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
err = cursor.All(ctx, dest)
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Find executed in %v\n", time.Since(start))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecuteAggregate executes a MongoDB aggregation pipeline
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
pipeline, err := mqb.BuildAggregateQuery(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
cursor, err := collection.Aggregate(ctx, pipeline.([]bson.D))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
err = cursor.All(ctx, dest)
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Aggregate executed in %v\n", time.Since(start))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecuteCount executes a MongoDB count query
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error) {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return 0, fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
filter, _, err := mqb.BuildFindQuery(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
count, err := collection.CountDocuments(ctx, filter.(bson.M))
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Count executed in %v\n", time.Since(start))
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ExecuteInsert executes a MongoDB insert operation
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error) {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
document := bson.M{}
|
||||
for i, col := range data.Columns {
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", col)
|
||||
}
|
||||
document[col] = data.Values[i]
|
||||
}
|
||||
|
||||
// Handle JSON values
|
||||
for col, val := range data.JsonValues {
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", col)
|
||||
}
|
||||
document[col] = val
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := collection.InsertOne(ctx, document)
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Insert executed in %v\n", time.Since(start))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ExecuteUpdate executes a MongoDB update operation
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error) {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
filter, err := mqb.buildFilter(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
update := bson.M{"$set": bson.M{}}
|
||||
for i, col := range updateData.Columns {
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", col)
|
||||
}
|
||||
update["$set"].(bson.M)[col] = updateData.Values[i]
|
||||
}
|
||||
|
||||
// Handle JSON updates
|
||||
for col, jsonUpdate := range updateData.JsonUpdates {
|
||||
if mqb.allowedFields != nil && !mqb.allowedFields[col] {
|
||||
return nil, fmt.Errorf("disallowed field: %s", col)
|
||||
}
|
||||
// Use dot notation for nested JSON updates
|
||||
update["$set"].(bson.M)[col+"."+jsonUpdate.Path] = jsonUpdate.Value
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := collection.UpdateMany(ctx, filter, update)
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Update executed in %v\n", time.Since(start))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ExecuteDelete executes a MongoDB delete operation
|
||||
func (mqb *MongoQueryBuilderImpl) ExecuteDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error) {
|
||||
// Security check for collection name
|
||||
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[collection.Name()] {
|
||||
return nil, fmt.Errorf("disallowed collection: %s", collection.Name())
|
||||
}
|
||||
|
||||
filter, err := mqb.buildFilter(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set timeout if not already in context
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline && mqb.queryTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(mqb.queryTimeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := collection.DeleteMany(ctx, filter)
|
||||
if mqb.enableQueryLogging {
|
||||
fmt.Printf("[DEBUG] MongoDB Delete executed in %v\n", time.Since(start))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QueryParserImpl implements QueryParser interface
|
||||
type QueryParserImpl struct {
|
||||
defaultLimit int
|
||||
maxLimit int
|
||||
}
|
||||
|
||||
// NewQueryParser creates a new query parser instance
|
||||
func NewQueryParser() *QueryParserImpl {
|
||||
return &QueryParserImpl{defaultLimit: 10, maxLimit: 100}
|
||||
}
|
||||
|
||||
// SetLimits sets default and maximum limits for pagination
|
||||
func (qp *QueryParserImpl) SetLimits(defaultLimit, maxLimit int) QueryParser {
|
||||
qp.defaultLimit = defaultLimit
|
||||
qp.maxLimit = maxLimit
|
||||
return qp
|
||||
}
|
||||
|
||||
// ParseQuery parses URL query parameters into a DynamicQuery struct
|
||||
func (qp *QueryParserImpl) ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error) {
|
||||
query := DynamicQuery{
|
||||
From: defaultTable,
|
||||
Limit: qp.defaultLimit,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
// Convert values to url.Values
|
||||
var urlValues url.Values
|
||||
switch v := values.(type) {
|
||||
case url.Values:
|
||||
urlValues = v
|
||||
case map[string][]string:
|
||||
urlValues = make(url.Values)
|
||||
for key, vals := range v {
|
||||
urlValues[key] = vals
|
||||
}
|
||||
default:
|
||||
return query, fmt.Errorf("unsupported values type: %T", values)
|
||||
}
|
||||
|
||||
// Parse fields
|
||||
if fields := urlValues.Get("fields"); fields != "" {
|
||||
if fields == "*" {
|
||||
query.Fields = []SelectField{{Expression: "*"}}
|
||||
} else {
|
||||
fieldList := strings.Split(fields, ",")
|
||||
for _, field := range fieldList {
|
||||
query.Fields = append(query.Fields, SelectField{Expression: strings.TrimSpace(field)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
query.Fields = []SelectField{{Expression: "*"}}
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
if limit := urlValues.Get("limit"); limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= qp.maxLimit {
|
||||
query.Limit = l
|
||||
}
|
||||
}
|
||||
if offset := urlValues.Get("offset"); offset != "" {
|
||||
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
|
||||
query.Offset = o
|
||||
}
|
||||
}
|
||||
|
||||
// Parse filters
|
||||
filters, err := qp.parseFilters(urlValues)
|
||||
if err != nil {
|
||||
return query, err
|
||||
}
|
||||
query.Filters = filters
|
||||
|
||||
// Parse sorting
|
||||
sorts, err := qp.parseSorting(urlValues)
|
||||
if err != nil {
|
||||
return query, err
|
||||
}
|
||||
query.Sort = sorts
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// ParseQueryWithDefaultFields parses URL query parameters into a DynamicQuery struct with default fields
|
||||
func (qp *QueryParserImpl) ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error) {
|
||||
query, err := qp.ParseQuery(values, defaultTable)
|
||||
if err != nil {
|
||||
return query, err
|
||||
}
|
||||
|
||||
// If no fields specified, use default fields
|
||||
if len(query.Fields) == 0 || (len(query.Fields) == 1 && query.Fields[0].Expression == "*") {
|
||||
query.Fields = make([]SelectField, len(defaultFields))
|
||||
for i, field := range defaultFields {
|
||||
query.Fields[i] = SelectField{Expression: field}
|
||||
}
|
||||
}
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// parseFilters parses filter parameters from URL values
|
||||
func (qp *QueryParserImpl) parseFilters(values url.Values) ([]FilterGroup, error) {
|
||||
filterMap := make(map[string]map[string]string)
|
||||
for key, vals := range values {
|
||||
if strings.HasPrefix(key, "filter[") && strings.HasSuffix(key, "]") {
|
||||
parts := strings.Split(key[7:len(key)-1], "][")
|
||||
if len(parts) == 2 {
|
||||
column, operator := parts[0], parts[1]
|
||||
if filterMap[column] == nil {
|
||||
filterMap[column] = make(map[string]string)
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
filterMap[column][operator] = vals[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(filterMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var filters []DynamicFilter
|
||||
for column, operators := range filterMap {
|
||||
for opStr, value := range operators {
|
||||
operator := FilterOperator(opStr)
|
||||
var parsedValue interface{}
|
||||
switch operator {
|
||||
case OpIn, OpNotIn:
|
||||
if value != "" {
|
||||
parsedValue = strings.Split(value, ",")
|
||||
}
|
||||
case OpBetween, OpNotBetween:
|
||||
if value != "" {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 2 {
|
||||
parsedValue = []interface{}{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])}
|
||||
}
|
||||
}
|
||||
case OpNull, OpNotNull:
|
||||
parsedValue = nil
|
||||
default:
|
||||
parsedValue = value
|
||||
}
|
||||
filters = append(filters, DynamicFilter{Column: column, Operator: operator, Value: parsedValue})
|
||||
}
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []FilterGroup{{Filters: filters, LogicOp: "AND"}}, nil
|
||||
}
|
||||
|
||||
// parseSorting parses sorting parameters from URL values
|
||||
func (qp *QueryParserImpl) parseSorting(values url.Values) ([]SortField, error) {
|
||||
sortParam := values.Get("sort")
|
||||
if sortParam == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var sorts []SortField
|
||||
fields := strings.Split(sortParam, ",")
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
order, column := "ASC", field
|
||||
if strings.HasPrefix(field, "-") {
|
||||
order = "DESC"
|
||||
column = field[1:]
|
||||
} else if strings.HasPrefix(field, "+") {
|
||||
column = field[1:]
|
||||
}
|
||||
sorts = append(sorts, SortField{Column: column, Order: order})
|
||||
}
|
||||
return sorts, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSQLQueryBuilder_BuildWhereClause(t *testing.T) {
|
||||
// Menggunakan PostgreSQL sebagai dialek pengujian
|
||||
builder := NewSQLQueryBuilder(DBTypePostgreSQL)
|
||||
allowedColumns := []string{
|
||||
"age",
|
||||
"status",
|
||||
"category",
|
||||
"price",
|
||||
"is_featured",
|
||||
"id",
|
||||
"deleted_at",
|
||||
"updated_at",
|
||||
"created_at", // Diperlukan untuk perbandingan kolom-ke-kolom
|
||||
"name", // Untuk test LIKE
|
||||
"rating", // Untuk test BETWEEN
|
||||
}
|
||||
// Daftarkan semua kolom yang digunakan dalam tes sebagai kolom yang diizinkan.
|
||||
// Ini penting karena security check (validasi kolom) diaktifkan secara default.
|
||||
builder.SetAllowedColumns(allowedColumns)
|
||||
|
||||
t.Run("should build single group with AND logic", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "age", Operator: OpGreaterThan, Value: 30},
|
||||
{Column: "status", Operator: OpEqual, Value: "active"},
|
||||
},
|
||||
LogicOp: "AND",
|
||||
},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("age" > ? AND "status" = ?)`, sql)
|
||||
assert.Equal(t, []interface{}{30, "active"}, args)
|
||||
})
|
||||
|
||||
t.Run("should build multiple groups with OR logic", func(t *testing.T) {
|
||||
// Skenario ini menguji: (category = 'electronics' AND price < 1000) OR (is_featured = true)
|
||||
filters := []FilterGroup{
|
||||
{ // Grup 1
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "category", Operator: OpEqual, Value: "electronics"},
|
||||
{Column: "price", Operator: OpLessThan, Value: 1000},
|
||||
},
|
||||
LogicOp: "AND",
|
||||
},
|
||||
{ // Grup 2
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "is_featured", Operator: OpEqual, Value: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
|
||||
assert.NoError(t, err)
|
||||
// Memastikan logika OR antar grup berjalan dengan benar
|
||||
assert.Equal(t, `("category" = ? AND "price" < ?) OR ("is_featured" = ?)`, sql)
|
||||
assert.Equal(t, []interface{}{"electronics", 1000, true}, args)
|
||||
})
|
||||
|
||||
t.Run("should handle IN operator with slice", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "id", Operator: OpIn, Value: []interface{}{1, 2, 3}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("id" IN (?,?,?))`, sql)
|
||||
assert.Equal(t, []interface{}{1, 2, 3}, args)
|
||||
})
|
||||
|
||||
t.Run("should handle IS NULL operator", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "deleted_at", Operator: OpNull, Value: nil},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("deleted_at" IS NULL)`, sql)
|
||||
assert.Nil(t, args)
|
||||
})
|
||||
|
||||
t.Run("should handle column-to-column comparison", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
// Membandingkan kolom updated_at dengan kolom created_at
|
||||
{Column: "updated_at", Operator: OpGreaterThan, Value: "users.created_at"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("updated_at" > "users"."created_at")`, sql)
|
||||
assert.Nil(t, args, "Column-to-column comparison should not produce arguments")
|
||||
})
|
||||
|
||||
t.Run("should handle LIKE and ILIKE operators", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "name", Operator: OpLike, Value: "%John%"},
|
||||
{Column: "category", Operator: OpILike, Value: "ELECTRONICS"},
|
||||
},
|
||||
LogicOp: "AND",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with PostgreSQL which supports ILIKE natively
|
||||
pgBuilder := NewSQLQueryBuilder(DBTypePostgreSQL).SetAllowedColumns(allowedColumns)
|
||||
sql, args, err := pgBuilder.BuildWhereClause(filters)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("name" LIKE ? AND "category" ILIKE ?)`, sql)
|
||||
assert.Equal(t, []interface{}{"%John%", "ELECTRONICS"}, args)
|
||||
|
||||
// Test with MySQL which simulates ILIKE with LOWER()
|
||||
mysqlBuilder := NewSQLQueryBuilder(DBTypeMySQL).SetAllowedColumns(allowedColumns)
|
||||
sql, args, err = mysqlBuilder.BuildWhereClause(filters)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "(`name` LIKE ? AND LOWER(`category`) LIKE LOWER(?))", sql)
|
||||
assert.Equal(t, []interface{}{"%John%", "ELECTRONICS"}, args)
|
||||
})
|
||||
|
||||
t.Run("should handle BETWEEN operator", func(t *testing.T) {
|
||||
filters := []FilterGroup{
|
||||
{
|
||||
Filters: []DynamicFilter{
|
||||
{Column: "rating", Operator: OpBetween, Value: []interface{}{4.0, 5.0}},
|
||||
},
|
||||
},
|
||||
}
|
||||
// This test uses the original `builder` which is configured for PostgreSQL
|
||||
sql, args, err := builder.BuildWhereClause(filters)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `("rating" BETWEEN ? AND ?)`, sql)
|
||||
assert.Equal(t, []interface{}{4.0, 5.0}, args)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
// buildFilterCondition builds a single filter condition with dialect-specific logic
|
||||
func isColumnReference(val string) bool {
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
// Remove backslash-escaped quotes
|
||||
unquoted := strings.ReplaceAll(val, "\\\"", "")
|
||||
// Split by dot
|
||||
parts := strings.Split(unquoted, ".")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
// Both parts should be alphanumeric or quoted identifiers
|
||||
validPart := func(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Allow quoted parts: "Quoted"
|
||||
if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
|
||||
return true
|
||||
}
|
||||
for _, r := range s {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !validPart(parts[0]) || !validPart(parts[1]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (qb *SQLQueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) {
|
||||
column := qb.validateAndEscapeColumn(filter.Column)
|
||||
if column == "" {
|
||||
return "", nil, fmt.Errorf("invalid or disallowed column: %s", filter.Column)
|
||||
}
|
||||
|
||||
// Handle column-to-column comparison if filter.Value is a column reference string
|
||||
if valStr, ok := filter.Value.(string); ok && isColumnReference(valStr) {
|
||||
escapedVal := qb.escapeColumnReference(valStr)
|
||||
switch filter.Operator {
|
||||
case OpEqual:
|
||||
return fmt.Sprintf("%s = %s", column, escapedVal), nil, nil
|
||||
case OpNotEqual:
|
||||
return fmt.Sprintf("%s <> %s", column, escapedVal), nil, nil
|
||||
case OpGreaterThan:
|
||||
return fmt.Sprintf("%s > %s", column, escapedVal), nil, nil
|
||||
case OpLessThan:
|
||||
return fmt.Sprintf("%s < %s", column, escapedVal), nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON operations
|
||||
switch filter.Operator {
|
||||
case OpJsonContains, OpJsonNotContains, OpJsonExists, OpJsonNotExists, OpJsonEqual, OpJsonNotEqual:
|
||||
return qb.dialect.BuildJsonFilter(column, filter)
|
||||
case OpArrayContains, OpArrayNotContains, OpArrayLength:
|
||||
return qb.dialect.BuildArrayFilter(column, filter)
|
||||
}
|
||||
|
||||
// Handle standard operators
|
||||
switch filter.Operator {
|
||||
case OpEqual:
|
||||
if filter.Value == nil {
|
||||
return fmt.Sprintf("%s IS NULL", column), nil, nil
|
||||
}
|
||||
return fmt.Sprintf("%s = ?", column), []interface{}{filter.Value}, nil
|
||||
case OpNotEqual:
|
||||
if filter.Value == nil {
|
||||
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
|
||||
}
|
||||
return fmt.Sprintf("%s <> ?", column), []interface{}{filter.Value}, nil
|
||||
case OpLike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
return fmt.Sprintf("%s LIKE ?", column), []interface{}{filter.Value}, nil
|
||||
case OpILike:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
return qb.dialect.BuildCaseInsensitiveLike(column), []interface{}{filter.Value}, nil
|
||||
case OpIn, OpNotIn:
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) == 0 {
|
||||
return "1=0", nil, nil
|
||||
}
|
||||
op := "IN"
|
||||
if filter.Operator == OpNotIn {
|
||||
op = "NOT IN"
|
||||
}
|
||||
placeholders := squirrel.Placeholders(len(values))
|
||||
return fmt.Sprintf("%s %s (%s)", column, op, placeholders), values, nil
|
||||
case OpGreaterThan, OpGreaterThanEqual, OpLessThan, OpLessThanEqual:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
opMap := map[FilterOperator]string{
|
||||
OpGreaterThan: ">",
|
||||
OpGreaterThanEqual: ">=",
|
||||
OpLessThan: "<",
|
||||
OpLessThanEqual: "<=",
|
||||
}
|
||||
op, ok := opMap[filter.Operator]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unhandled comparison operator: %s", filter.Operator)
|
||||
}
|
||||
return fmt.Sprintf("%s %s ?", column, op), []interface{}{filter.Value}, nil
|
||||
case OpBetween, OpNotBetween:
|
||||
values := qb.parseArrayValue(filter.Value)
|
||||
if len(values) != 2 {
|
||||
return "", nil, fmt.Errorf("between operator requires exactly 2 values")
|
||||
}
|
||||
op := "BETWEEN"
|
||||
if filter.Operator == OpNotBetween {
|
||||
op = "NOT BETWEEN"
|
||||
}
|
||||
return fmt.Sprintf("%s %s ? AND ?", column, op), []interface{}{values[0], values[1]}, nil
|
||||
case OpNull:
|
||||
return fmt.Sprintf("%s IS NULL", column), nil, nil
|
||||
case OpNotNull:
|
||||
return fmt.Sprintf("%s IS NOT NULL", column), nil, nil
|
||||
case OpContains, OpNotContains, OpStartsWith, OpEndsWith:
|
||||
if filter.Value == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
var value string
|
||||
switch filter.Operator {
|
||||
case OpContains, OpNotContains:
|
||||
value = fmt.Sprintf("%%%v%%", filter.Value)
|
||||
case OpStartsWith:
|
||||
value = fmt.Sprintf("%v%%", filter.Value)
|
||||
case OpEndsWith:
|
||||
value = fmt.Sprintf("%%%v", filter.Value)
|
||||
}
|
||||
|
||||
switch qb.dbType {
|
||||
case DBTypePostgreSQL, DBTypeSQLite:
|
||||
op := "ILIKE"
|
||||
if strings.Contains(string(filter.Operator), "Not") {
|
||||
op = "NOT ILIKE"
|
||||
}
|
||||
return fmt.Sprintf("%s %s ?", column, op), []interface{}{value}, nil
|
||||
case DBTypeMySQL, DBTypeSQLServer:
|
||||
op := "LIKE"
|
||||
if strings.Contains(string(filter.Operator), "Not") {
|
||||
op = "NOT LIKE"
|
||||
}
|
||||
return fmt.Sprintf("LOWER(%s) %s LOWER(?)", column, op), []interface{}{value}, nil
|
||||
default:
|
||||
op := "LIKE"
|
||||
if strings.Contains(string(filter.Operator), "Not") {
|
||||
op = "NOT LIKE"
|
||||
}
|
||||
return fmt.Sprintf("%s %s ?", column, op), []interface{}{value}, nil
|
||||
}
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
|
||||
}
|
||||
}
|
||||
|
||||
// parseArrayValue parses an array value for SQL queries
|
||||
func (qb *SQLQueryBuilder) parseArrayValue(value interface{}) []interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if reflect.TypeOf(value).Kind() == reflect.Slice {
|
||||
v := reflect.ValueOf(value)
|
||||
result := make([]interface{}, v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
result[i] = v.Index(i).Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
if strings.Contains(str, ",") {
|
||||
parts := strings.Split(str, ",")
|
||||
result := make([]interface{}, len(parts))
|
||||
for i, part := range parts {
|
||||
result[i] = strings.TrimSpace(part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return []interface{}{str}
|
||||
}
|
||||
return []interface{}{value}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/xwb1989/sqlparser"
|
||||
)
|
||||
|
||||
// validateAndEscapeColumn validates and escapes a column name
|
||||
func (qb *SQLQueryBuilder) validateAndEscapeColumn(field string) string {
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
// Allow complex expressions like functions
|
||||
if strings.Contains(field, "(") {
|
||||
if qb.isValidExpression(field) {
|
||||
return field // Don't escape complex expressions, assume they are safe
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// For simple column names (not containing a dot), check against the allow list.
|
||||
// For dotted names (table.column), we assume the check is not needed or handled elsewhere.
|
||||
// The dialect's EscapeIdentifier will handle splitting and escaping each part of a dotted name.
|
||||
if !strings.Contains(field, ".") {
|
||||
if qb.allowedColumns != nil && !qb.allowedColumns[field] {
|
||||
// Log or handle disallowed column
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate the actual escaping to the dialect, which handles simple and dotted names correctly.
|
||||
return qb.dialect.EscapeIdentifier(field)
|
||||
}
|
||||
|
||||
// PERBAIKAN AKHIR: Sanitasi SQL penuh untuk kompatibilitas parser MySQL
|
||||
func (qb *SQLQueryBuilder) validateParsedSQL(sql string) error {
|
||||
// --- Langkah 1: Sanitasi untuk membuat SQL kompatibel dengan parser MySQL ---
|
||||
|
||||
// PERBAIKAN 1: Ganti quote PostgreSQL (") dengan quote MySQL (`)
|
||||
sanitizedSQL := strings.ReplaceAll(sql, `"`, "`")
|
||||
|
||||
// PERBAIKAN 2: Ganti placeholder PostgreSQL ($1, $2, ...) dengan placeholder MySQL (?)
|
||||
// Regex ini mencocokan '$' diikuti oleh satu atau lebih digit.
|
||||
re := regexp.MustCompile(`\$\d+`)
|
||||
sanitizedSQL = re.ReplaceAllString(sanitizedSQL, "?")
|
||||
|
||||
// PERBAIKAN 3: Ganti ILIKE dengan LIKE karena parser MySQL tidak mengenali ILIKE
|
||||
reILike := regexp.MustCompile(`(?i)\bilike\b`)
|
||||
sanitizedSQL = reILike.ReplaceAllString(sanitizedSQL, "LIKE")
|
||||
|
||||
// --- Langkah 2: Parse SQL yang sudah disanitasi ---
|
||||
stmt, err := sqlparser.Parse(sanitizedSQL)
|
||||
if err != nil {
|
||||
// Jika SQL tidak valid (bahkan untuk parser MySQL), kita anggap ini tidak aman.
|
||||
return fmt.Errorf("invalid SQL syntax detected during validation: %w", err)
|
||||
}
|
||||
|
||||
// --- Langkah 3: Validasi struktur query (logikanya tetap sama) ---
|
||||
switch stmt.(type) {
|
||||
case *sqlparser.Select:
|
||||
// Izinkan statement SELECT sederhana.
|
||||
return nil // Aman
|
||||
|
||||
case *sqlparser.Union:
|
||||
// Tolak statement UNION.
|
||||
return fmt.Errorf("UNION statements are not allowed in this context")
|
||||
|
||||
case *sqlparser.Insert, *sqlparser.Update, *sqlparser.Delete:
|
||||
// Tolak statement DML.
|
||||
return fmt.Errorf("DML statements (INSERT/UPDATE/DELETE) are not allowed from user input")
|
||||
|
||||
case *sqlparser.DDL:
|
||||
// Tolak statement DDL.
|
||||
return fmt.Errorf("DDL statements are not allowed from user input")
|
||||
|
||||
default:
|
||||
// Blokir statement lain.
|
||||
return fmt.Errorf("unsupported or disallowed SQL statement type: %T", stmt)
|
||||
}
|
||||
}
|
||||
func (qb *SQLQueryBuilder) isValidExpression(expr string) bool {
|
||||
// This is a simplified check. A more robust solution might use a proper SQL parser library.
|
||||
// For now, we allow alphanumeric, underscore, dots, parentheses, and common operators.
|
||||
// For SQL Server, allow brackets [] and spaces for column names.
|
||||
allowedChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.,() *-/[]"
|
||||
for _, r := range expr {
|
||||
if !strings.ContainsRune(allowedChars, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PERBAIKAN: Gunakan Regex dengan word boundary untuk menghindari false positive
|
||||
// Ini akan mencegah "DeletedAt" dianggap sebagai "delete"
|
||||
dangerousPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(--|/\*|\*/)`), // SQL comments
|
||||
regexp.MustCompile(`(?i)\bunion\b\s+\bselect\b`), // UNION followed by SELECT
|
||||
regexp.MustCompile(`(?i)\bselect\b`), // Standalone SELECT (bisa disesuaikan)
|
||||
regexp.MustCompile(`(?i)\binsert\b`), // Standalone INSERT
|
||||
regexp.MustCompile(`(?i)\bupdate\b`), // Standalone UPDATE
|
||||
regexp.MustCompile(`(?i)\bdelete\b`), // Standalone DELETE
|
||||
regexp.MustCompile(`(?i)\bdrop\b`), // DROP
|
||||
regexp.MustCompile(`(?i)\balter\b`), // ALTER
|
||||
regexp.MustCompile(`(?i)\bcreate\b`), // CREATE
|
||||
regexp.MustCompile(`(?i)\b(exec|execute)\s*\(`), // EXEC/EXECUTE functions
|
||||
regexp.MustCompile(`(?i)\b(waitfor\s+delay|benchmark|sleep)\s*\(`), // Time-based attacks
|
||||
regexp.MustCompile(`(?i)\b(information_schema|sysobjects|syscolumns)\b`), // DB enumeration
|
||||
}
|
||||
|
||||
lowerExpr := strings.ToLower(expr)
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if pattern.MatchString(lowerExpr) {
|
||||
// Log untuk debugging, jadi kita tahu pola mana yang cocok
|
||||
// fmt.Printf("[DEBUG] Potentially dangerous expression detected: %s matched by pattern %s\n", expr, pattern.String())
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isValidFunctionName validates if a function name is valid
|
||||
func (qb *SQLQueryBuilder) isValidFunctionName(name string) bool {
|
||||
// Check if the function name is a valid SQL function
|
||||
validFunctions := map[string]bool{
|
||||
// Aggregate functions
|
||||
"count": true, "sum": true, "avg": true, "min": true, "max": true,
|
||||
// Window functions
|
||||
"row_number": true, "rank": true, "dense_rank": true, "ntile": true,
|
||||
"lag": true, "lead": true, "first_value": true, "last_value": true,
|
||||
// JSON functions
|
||||
"json_extract": true, "json_contains": true, "json_search": true,
|
||||
"json_array": true, "json_object": true, "json_merge": true,
|
||||
// Other functions
|
||||
"concat": true, "substring": true, "upper": true, "lower": true,
|
||||
"trim": true, "coalesce": true, "nullif": true, "isnull": true,
|
||||
}
|
||||
|
||||
return validFunctions[strings.ToLower(name)]
|
||||
}
|
||||
|
||||
// escapeColumnReference escapes a column reference (table.column)
|
||||
func (qb *SQLQueryBuilder) escapeColumnReference(col string) string {
|
||||
return qb.dialect.EscapeIdentifier(col)
|
||||
}
|
||||
|
||||
// escapeJsonPath escapes a JSON path for PostgreSQL
|
||||
func (qb *SQLQueryBuilder) escapeJsonPath(path string) string {
|
||||
// Simple implementation - in a real scenario, you'd need more sophisticated escaping
|
||||
return "'" + strings.ReplaceAll(path, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// escapeSqlServerJsonPath escapes a JSON path for SQL Server
|
||||
func (qb *SQLQueryBuilder) escapeSqlServerJsonPath(path string) string {
|
||||
// Convert JSONPath to SQL Server format
|
||||
// $.path.to.property -> '$.path.to.property'
|
||||
if !strings.HasPrefix(path, "$") {
|
||||
path = "$." + path
|
||||
}
|
||||
return strings.ReplaceAll(path, ".", ".")
|
||||
}
|
||||
|
||||
// checkForSqlInjectionInArgs checks for potential SQL injection patterns in query arguments
|
||||
func (qb *SQLQueryBuilder) checkForSqlInjectionInArgs(args []interface{}) error {
|
||||
if !qb.enableSecurityChecks {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
if str, ok := arg.(string); ok {
|
||||
lowerStr := strings.ToLower(str)
|
||||
// Check for dangerous patterns specifically in user input values
|
||||
dangerousPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(union\s+select)`),
|
||||
regexp.MustCompile(`(?i)(or\s+1\s*=\s*1)`),
|
||||
regexp.MustCompile(`(?i)(and\s+true)`),
|
||||
regexp.MustCompile(`(?i)(waitfor\s+delay)`),
|
||||
regexp.MustCompile(`(?i)(benchmark|sleep)\s*\(`),
|
||||
regexp.MustCompile(`(?i)(pg_sleep)\s*\(`),
|
||||
regexp.MustCompile(`(?i)(load_file|into\s+outfile)`),
|
||||
regexp.MustCompile(`(?i)(information_schema|sysobjects|syscolumns)`),
|
||||
regexp.MustCompile(`(?i)(--|\/\*|\*\/)`),
|
||||
}
|
||||
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if pattern.MatchString(lowerStr) {
|
||||
return fmt.Errorf("potential SQL injection detected in query argument: pattern %s matched", pattern.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkForSqlInjectionInSQL checks for potential SQL injection patterns in the final SQL
|
||||
func (qb *SQLQueryBuilder) checkForSqlInjectionInSQL(sql string) error {
|
||||
if !qb.enableSecurityChecks {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for dangerous patterns in the final SQL
|
||||
// But allow valid SQL keywords in their proper context
|
||||
lowerSQL := strings.ToLower(sql)
|
||||
|
||||
// More specific patterns that actually indicate injection attempts
|
||||
dangerousPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(union\s+select)`), // UNION followed by SELECT
|
||||
regexp.MustCompile(`(?i)(select\s+.*\s+from\s+.*\s+where\s+.*\s+or\s+1\s*=\s*1)`), // Classic SQL injection
|
||||
regexp.MustCompile(`(?i)(drop\s+table)`), // DROP TABLE
|
||||
regexp.MustCompile(`(?i)(delete\s+from)`), // DELETE FROM
|
||||
regexp.MustCompile(`(?i)(insert\s+into)`), // INSERT INTO
|
||||
regexp.MustCompile(`(?i)(update\s+.*\s+set)`), // UPDATE SET
|
||||
regexp.MustCompile(`(?i)(alter\s+table)`), // ALTER TABLE
|
||||
regexp.MustCompile(`(?i)(create\s+table)`), // CREATE TABLE
|
||||
regexp.MustCompile(`(?i)(exec\s*\(|execute\s*\()`), // EXEC/EXECUTE functions
|
||||
regexp.MustCompile(`(?i)(--|\/\*|\*\/)`), // SQL comments
|
||||
}
|
||||
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if pattern.MatchString(lowerSQL) {
|
||||
return fmt.Errorf("potential SQL injection detected in SQL: pattern %s matched", pattern.String())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
// DBType represents the type of database
|
||||
type DBType string
|
||||
|
||||
const (
|
||||
DBTypePostgreSQL DBType = "postgres"
|
||||
DBTypeMySQL DBType = "mysql"
|
||||
DBTypeSQLite DBType = "sqlite"
|
||||
DBTypeSQLServer DBType = "sqlserver"
|
||||
DBTypeMongoDB DBType = "mongodb"
|
||||
)
|
||||
|
||||
// FilterOperator represents supported filter operators
|
||||
type FilterOperator string
|
||||
|
||||
const (
|
||||
OpEqual FilterOperator = "_eq"
|
||||
OpNotEqual FilterOperator = "_neq"
|
||||
OpLike FilterOperator = "_like"
|
||||
OpILike FilterOperator = "_ilike"
|
||||
OpNotLike FilterOperator = "_nlike"
|
||||
OpNotILike FilterOperator = "_nilike"
|
||||
OpIn FilterOperator = "_in"
|
||||
OpNotIn FilterOperator = "_nin"
|
||||
OpGreaterThan FilterOperator = "_gt"
|
||||
OpGreaterThanEqual FilterOperator = "_gte"
|
||||
OpLessThan FilterOperator = "_lt"
|
||||
OpLessThanEqual FilterOperator = "_lte"
|
||||
OpBetween FilterOperator = "_between"
|
||||
OpNotBetween FilterOperator = "_nbetween"
|
||||
OpNull FilterOperator = "_null"
|
||||
OpNotNull FilterOperator = "_nnull"
|
||||
OpContains FilterOperator = "_contains"
|
||||
OpNotContains FilterOperator = "_ncontains"
|
||||
OpStartsWith FilterOperator = "_starts_with"
|
||||
OpEndsWith FilterOperator = "_ends_with"
|
||||
OpJsonContains FilterOperator = "_json_contains"
|
||||
OpJsonNotContains FilterOperator = "_json_ncontains"
|
||||
OpJsonExists FilterOperator = "_json_exists"
|
||||
OpJsonNotExists FilterOperator = "_json_nexists"
|
||||
OpJsonEqual FilterOperator = "_json_eq"
|
||||
OpJsonNotEqual FilterOperator = "_json_neq"
|
||||
OpArrayContains FilterOperator = "_array_contains"
|
||||
OpArrayNotContains FilterOperator = "_array_ncontains"
|
||||
OpArrayLength FilterOperator = "_array_length"
|
||||
)
|
||||
|
||||
// DynamicFilter represents a single filter condition
|
||||
type DynamicFilter struct {
|
||||
Column string `json:"column"`
|
||||
Operator FilterOperator `json:"operator"`
|
||||
Value interface{} `json:"value"`
|
||||
// Additional options for complex filters
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// FilterGroup represents a group of filters with a logical operator (AND/OR)
|
||||
type FilterGroup struct {
|
||||
Filters []DynamicFilter `json:"filters"`
|
||||
LogicOp string `json:"logic_op"` // AND, OR
|
||||
}
|
||||
|
||||
// SelectField represents a field in the SELECT clause, supporting expressions and aliases
|
||||
type SelectField struct {
|
||||
Expression string `json:"expression"` // e.g., "TMLogBarang.Nama", "COUNT(*)"
|
||||
Alias string `json:"alias"` // e.g., "obat_nama", "total_count"
|
||||
// Window function support
|
||||
WindowFunction *WindowFunction `json:"window_function,omitempty"`
|
||||
}
|
||||
|
||||
// WindowFunction represents a window function with its configuration
|
||||
type WindowFunction struct {
|
||||
Function string `json:"function"` // e.g., "ROW_NUMBER", "RANK", "DENSE_RANK", "LEAD", "LAG"
|
||||
Over string `json:"over"` // PARTITION BY expression
|
||||
OrderBy string `json:"order_by"` // ORDER BY expression
|
||||
Frame string `json:"frame"` // ROWS/RANGE clause
|
||||
Alias string `json:"alias"` // Alias for the window function
|
||||
}
|
||||
|
||||
// Join represents a JOIN clause
|
||||
type Join struct {
|
||||
Type string `json:"type"` // "INNER", "LEFT", "RIGHT", "FULL"
|
||||
Table string `json:"table"` // Table name to join
|
||||
Alias string `json:"alias"` // Table alias
|
||||
OnConditions FilterGroup `json:"on_conditions"` // Conditions for the ON clause
|
||||
// LATERAL JOIN support
|
||||
Lateral bool `json:"lateral,omitempty"`
|
||||
}
|
||||
|
||||
// Union represents a UNION clause
|
||||
type Union struct {
|
||||
Type string `json:"type"` // "UNION", "UNION ALL"
|
||||
Query DynamicQuery `json:"query"` // The subquery to union with
|
||||
}
|
||||
|
||||
// CTE (Common Table Expression) represents a WITH clause
|
||||
type CTE struct {
|
||||
Name string `json:"name"` // CTE alias name
|
||||
Query DynamicQuery `json:"query"` // The query defining the CTE
|
||||
// Recursive CTE support
|
||||
Recursive bool `json:"recursive,omitempty"`
|
||||
}
|
||||
|
||||
// DynamicQuery represents the complete query structure
|
||||
type DynamicQuery struct {
|
||||
Fields []SelectField `json:"fields,omitempty"`
|
||||
From string `json:"from"` // Main table name
|
||||
Aliases string `json:"aliases"` // Main table alias
|
||||
Joins []Join `json:"joins,omitempty"`
|
||||
Filters []FilterGroup `json:"filters,omitempty"`
|
||||
GroupBy []string `json:"group_by,omitempty"`
|
||||
Having []FilterGroup `json:"having,omitempty"`
|
||||
Unions []Union `json:"unions,omitempty"`
|
||||
CTEs []CTE `json:"ctes,omitempty"`
|
||||
Sort []SortField `json:"sort,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
// Window function support
|
||||
WindowFunctions []WindowFunction `json:"window_functions,omitempty"`
|
||||
// JSON operations
|
||||
JsonOperations []JsonOperation `json:"json_operations,omitempty"`
|
||||
}
|
||||
|
||||
// JsonOperation represents a JSON operation
|
||||
type JsonOperation struct {
|
||||
Type string `json:"type"` // "extract", "exists", "contains", etc.
|
||||
Column string `json:"column"` // JSON column
|
||||
Path string `json:"path"` // JSON path
|
||||
Value interface{} `json:"value,omitempty"` // Value for comparison
|
||||
Alias string `json:"alias,omitempty"` // Alias for the result
|
||||
}
|
||||
|
||||
// SortField represents sorting configuration
|
||||
type SortField struct {
|
||||
Column string `json:"column"`
|
||||
Order string `json:"order"` // ASC, DESC
|
||||
}
|
||||
|
||||
// UpdateData represents data for UPDATE operations
|
||||
type UpdateData struct {
|
||||
Columns []string `json:"columns"`
|
||||
Values []interface{} `json:"values"`
|
||||
// JSON update support
|
||||
JsonUpdates map[string]JsonUpdate `json:"json_updates,omitempty"`
|
||||
}
|
||||
|
||||
// JsonUpdate represents a JSON update operation
|
||||
type JsonUpdate struct {
|
||||
Path string `json:"path"` // JSON path
|
||||
Value interface{} `json:"value"` // New value
|
||||
}
|
||||
|
||||
// InsertData represents data for INSERT operations
|
||||
type InsertData struct {
|
||||
Columns []string `json:"columns"`
|
||||
Values []interface{} `json:"values"`
|
||||
// JSON insert support
|
||||
JsonValues map[string]interface{} `json:"json_values,omitempty"`
|
||||
}
|
||||
|
||||
// QueryBuilder interface defines the contract for query builders
|
||||
type QueryBuilder interface {
|
||||
// Configuration methods
|
||||
SetSecurityOptions(enableChecks bool, maxRows int) QueryBuilder
|
||||
SetAllowedColumns(columns []string) QueryBuilder
|
||||
SetAllowedTables(tables []string) QueryBuilder
|
||||
SetQueryLogging(enable bool) QueryBuilder
|
||||
SetQueryTimeout(timeout time.Duration) QueryBuilder
|
||||
|
||||
// SQL building methods
|
||||
BuildQuery(query DynamicQuery) (string, []interface{}, error)
|
||||
BuildCountQuery(query DynamicQuery) (string, []interface{}, error)
|
||||
BuildInsertQuery(table string, data InsertData, returningColumns ...string) (string, []interface{}, error)
|
||||
BuildUpdateQuery(table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error)
|
||||
BuildDeleteQuery(table string, filters []FilterGroup, returningColumns ...string) (string, []interface{}, error)
|
||||
BuildUpsertQuery(table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (string, []interface{}, error)
|
||||
BuildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error)
|
||||
|
||||
// SQL execution methods
|
||||
ExecuteQuery(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error
|
||||
ExecuteQueryRow(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery, dest interface{}) error
|
||||
ExecuteCount(ctx context.Context, exec sqlx.ExtContext, query DynamicQuery) (int64, error)
|
||||
ExecuteInsert(ctx context.Context, exec sqlx.ExtContext, table string, data InsertData, returningColumns ...string) (sql.Result, error)
|
||||
ExecuteUpdate(ctx context.Context, exec sqlx.ExtContext, table string, updateData UpdateData, filters []FilterGroup, returningColumns ...string) (sql.Result, error)
|
||||
ExecuteDelete(ctx context.Context, exec sqlx.ExtContext, table string, filters []FilterGroup, returningColumns ...string) (sql.Result, error)
|
||||
ExecuteUpsert(ctx context.Context, exec sqlx.ExtContext, table string, insertData InsertData, conflictColumns []string, updateColumns []string, returningColumns ...string) (sql.Result, error)
|
||||
}
|
||||
|
||||
// MongoQueryBuilder interface defines the contract for MongoDB query builders
|
||||
type MongoQueryBuilder interface {
|
||||
// Configuration methods
|
||||
SetSecurityOptions(enableChecks bool, maxDocs int) MongoQueryBuilder
|
||||
SetAllowedFields(fields []string) MongoQueryBuilder
|
||||
SetAllowedCollections(collections []string) MongoQueryBuilder
|
||||
SetQueryLogging(enable bool) MongoQueryBuilder
|
||||
SetQueryTimeout(timeout time.Duration) MongoQueryBuilder
|
||||
|
||||
// MongoDB building methods
|
||||
BuildFindQuery(query DynamicQuery) (interface{}, interface{}, error)
|
||||
BuildAggregateQuery(query DynamicQuery) (interface{}, error)
|
||||
|
||||
// MongoDB execution methods
|
||||
ExecuteFind(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error
|
||||
ExecuteAggregate(ctx context.Context, collection *mongo.Collection, query DynamicQuery, dest interface{}) error
|
||||
ExecuteCount(ctx context.Context, collection *mongo.Collection, query DynamicQuery) (int64, error)
|
||||
ExecuteInsert(ctx context.Context, collection *mongo.Collection, data InsertData) (*mongo.InsertOneResult, error)
|
||||
ExecuteUpdate(ctx context.Context, collection *mongo.Collection, updateData UpdateData, filters []FilterGroup) (*mongo.UpdateResult, error)
|
||||
ExecuteDelete(ctx context.Context, collection *mongo.Collection, filters []FilterGroup) (*mongo.DeleteResult, error)
|
||||
}
|
||||
|
||||
// QueryParser interface defines the contract for query parsers
|
||||
type QueryParser interface {
|
||||
SetLimits(defaultLimit, maxLimit int) QueryParser
|
||||
ParseQuery(values interface{}, defaultTable string) (DynamicQuery, error)
|
||||
ParseQueryWithDefaultFields(values interface{}, defaultTable string, defaultFields []string) (DynamicQuery, error)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// TranslateError menerjemahkan error dari go-playground/validator menjadi pesan bahasa Indonesia.
|
||||
func TranslateError(err error) string {
|
||||
var ve validator.ValidationErrors
|
||||
|
||||
// Cek apakah error merupakan tipe ValidationErrors dari go-playground
|
||||
if errors.As(err, &ve) {
|
||||
var errMsgs []string
|
||||
for _, fe := range ve {
|
||||
// fe.Field() mengambil nama field struct (atau bisa pakai fe.Param() untuk parameter)
|
||||
switch fe.Tag() {
|
||||
case "required":
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' wajib diisi", fe.Field()))
|
||||
case "oneof":
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' harus bernilai salah satu dari: %s", fe.Field(), fe.Param()))
|
||||
case "min":
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' minimal harus %s karakter", fe.Field(), fe.Param()))
|
||||
case "max":
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' maksimal harus %s karakter", fe.Field(), fe.Param()))
|
||||
case "email":
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' harus berupa format email yang valid", fe.Field()))
|
||||
default:
|
||||
errMsgs = append(errMsgs, fmt.Sprintf("Kolom '%s' tidak valid pada validasi '%s'", fe.Field(), fe.Tag()))
|
||||
}
|
||||
}
|
||||
return strings.Join(errMsgs, ", ")
|
||||
}
|
||||
|
||||
// Kembalikan error asli jika bukan error validasi
|
||||
return err.Error()
|
||||
}
|
||||
Reference in New Issue
Block a user