first commit

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