#!/bin/bash # Advanced Context Generator for service # Usage: ./scripts/context.sh [OPTIONS] # Options: # -s, --sql FILE Parse SQL file and generate context (mutually exclusive with -j) # -j, --json FILE Parse JSON response file and generate context (mutually exclusive with -s) # -t, --table NAME Specify explicit table name (useful when using JSON) # -d, --dir PATH Custom directory structure (e.g., master/reference/province) (required) # -g, --generate TYPE What to generate: domain|handler|all (default: all) # -f, --format Output format for docs: code|markdown|json|yaml (default: code) # -o, --output Output directory for documentation (default: ./generated-contexts) # -v, --verbose Verbose output # -h, --help Show help set -uo pipefail # Colors for output readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly PURPLE='\033[0;35m' readonly CYAN='\033[0;36m' readonly WHITE='\033[1;37m' readonly NC='\033[0m' # No Color # Configuration readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" readonly DEFAULT_OUTPUT_DIR="${PROJECT_ROOT}/generated-contexts" readonly DEFAULT_FORMAT="code" readonly INTERNAL_DIR="${PROJECT_ROOT}/internal" # Global variables VERBOSE=false OUTPUT_DIR="${DEFAULT_OUTPUT_DIR}" FORMAT="${DEFAULT_FORMAT}" GENERATE_TYPE="all" # domain, handler, all SQL_FILE="" JSON_FILE="" TABLE_NAME="" CUSTOM_DIR="" # --- Helper Functions --- # Logging functions log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_verbose() { [[ "$VERBOSE" == true ]] && echo -e "${CYAN}[VERBOSE]${NC} $1"; } log_header() { echo -e "\n${PURPLE}==== $1 ====${NC}\n"; } # Help function show_help() { cat << EOF ${WHITE}Advanced Context Generator for service${NC} ${YELLOW}Usage:${NC} $(basename "$0") -s SQL_FILE -d CUSTOM_DIR [OPTIONS] $(basename "$0") -j JSON_FILE -d CUSTOM_DIR -t TABLE_NAME [OPTIONS] ${YELLOW}Required Options:${NC} -s, --sql FILE SQL file with CREATE TABLE statement -j, --json FILE JSON response payload file -d, --dir PATH Custom directory structure (e.g., master/reference/province) ${YELLOW}Optional Options:${NC} -t, --table NAME Explicit table name (recommended when using -j) -g, --generate TYPE What to generate: domain|handler|proto|grpc|all (default: all) - 'domain': Generates entity, dto, mapper, repository, service - 'handler': Generates HTTP handler - 'proto': Generates gRPC .proto file - 'grpc': Generates gRPC handler and mapper - 'all': Generates both domain and handler -o, --output Output directory for documentation (default: ${DEFAULT_OUTPUT_DIR}) -v, --verbose Verbose output -h, --help Show this help message ${YELLOW}Examples:${NC} # Generate domain + handler for Ethnic $(basename "$0") -s db/migrations/001_create_ethnic_table.sql -d master/reference/ethnic # Generate only domain files for Province $(basename "$0") -s db/migrations/002_create_province_table.sql -d master/reference/province -g domain # Generate only gRPC proto file for Pages $(basename "$0") -s db/migrations/001_create_role_pages.sql -d master/role/pages -g proto # Generate only HTTP handler for District $(basename "$0") -s db/migrations/003_create_district_table.sql -d master/reference/district -g handler EOF } # PascalCase from snake_case or kebab-case to_pascal_case() { local str="$1" echo "$str" | sed -E 's/(^|[-_.])([a-zA-Z])/\U\2/g' } to_snake_case() { local str="$1" # Tambahkan underscore sebelum huruf besar (kecuali di awal), lalu ubah ke huruf kecil echo "$str" | sed 's/\([A-Z]\)/_\L\1/g' | sed 's/^_//' } # camelCase from snake_case or kebab-case to_camel_case() { local str="$1" local pascal=$(to_pascal_case "$str") echo "$(echo "${pascal:0:1}" | tr '[:upper:]' '[:lower:]')${pascal:1}" } to_lower_case() { local str="$1" echo "$str" | tr '[:upper:]' '[:lower:]' } # Helper: Cek apakah field ini merupakan kolom otomatis (managed) is_managed_field() { local col_name="$1" local lower_col=$(to_lower_case "$col_name") if [[ "$col_name" == "$DB_PK_NAME" || "$lower_col" == "created_at" || "$lower_col" == "createdat" || "$lower_col" == "updated_at" || "$lower_col" == "updatedat" || "$lower_col" == "deleted_at" || "$lower_col" == "deletedat" ]]; then return 0 fi return 1 } # Extract package name from custom directory structure # For path master/reference/province, returns "province" extract_package_name() { local custom_dir="$1" basename "$custom_dir" } # Convert SQL type to Go type sql_to_go_type() { local sql_type="$1" local nullable="${2:-false}" local go_type="interface{}" case "$sql_type" in "smallserial"|"serial"|"bigserial"|"smallint"|"integer"|"bigint"|"int") go_type="int64" ;; "varchar"|"text"|"char"|"character varying") go_type="string" ;; "boolean"|"bool") go_type="bool" ;; "timestamp"|"timestamptz"|"date") go_type="time.Time" ;; "decimal"|"numeric"|"real"|"double precision"|"float") go_type="float64" ;; "uuid") go_type="uuid.UUID" ;; esac # Gunakan pointer untuk kolom nullable agar tipe data strict if [[ "$nullable" == "true" && "$go_type" != "interface{}" ]]; then echo "*$go_type" else echo "$go_type" fi } # Convert SQL type to Protobuf type sql_to_proto_type() { local sql_type="$1" local nullable="${2:-false}" local proto_type="string" # Default case "$sql_type" in "smallserial"|"serial"|"bigserial"|"smallint"|"integer"|"bigint"|"int") proto_type="int64" ;; "varchar"|"text"|"char"|"character varying"|"uuid") proto_type="string" ;; "boolean"|"bool") proto_type="bool" ;; "timestamp"|"timestamptz"|"date") proto_type="google.protobuf.Timestamp" ;; "decimal"|"numeric") proto_type="string" ;; # Represent numeric as string for precision "real"|"double precision"|"float") proto_type="double" ;; esac # Use optional for nullable fields (proto3) if [[ "$nullable" == "true" ]]; then echo "optional $proto_type" else echo "$proto_type" fi } # --- Core Logic --- # Parse command line arguments parse_args() { if [[ "$#" -eq 0 ]]; then show_help exit 1 fi while [[ $# -gt 0 ]]; do case $1 in -s|--sql) SQL_FILE="$2" shift 2 ;; -j|--json) JSON_FILE="$2" shift 2 ;; -t|--table) TABLE_NAME="$2" shift 2 ;; -d|--dir) CUSTOM_DIR="$2" shift 2 ;; -g|--generate) GENERATE_TYPE="$2" if [[ "$GENERATE_TYPE" != "domain" && "$GENERATE_TYPE" != "handler" && "$GENERATE_TYPE" != "proto" && "$GENERATE_TYPE" != "grpc" && "$GENERATE_TYPE" != "all" ]]; then log_error "Invalid generate type: $GENERATE_TYPE. Use 'domain', 'handler', 'proto', 'grpc', or 'all'." exit 1 fi shift 2 ;; -f|--format) FORMAT="$2" shift 2 ;; -o|--output) OUTPUT_DIR="$2" shift 2 ;; -v|--verbose) VERBOSE=true shift ;; -h|--help) show_help exit 0 ;; *) log_error "Unknown option: $1" show_help exit 1 ;; esac done # Validate required arguments if [[ -z "$SQL_FILE" && -z "$JSON_FILE" ]]; then log_error "Either SQL file (-s) or JSON file (-j) is required." exit 1 fi if [[ -n "$SQL_FILE" && ! -f "$SQL_FILE" ]]; then log_error "SQL file not found: $SQL_FILE" exit 1 fi if [[ -n "$JSON_FILE" && ! -f "$JSON_FILE" ]]; then log_error "JSON file not found: $JSON_FILE" exit 1 fi if [[ -z "$CUSTOM_DIR" ]]; then log_error "Custom directory is required. Use -d or --dir." exit 1 fi } # Extract table info from SQL parse_sql_table() { local sql_file="$1" log_info "Parsing SQL table from: $sql_file" # Extract table name (more robust regex) local table_name table_name=$(grep -i "CREATE TABLE" "$sql_file" | sed -E 's/.*CREATE[[:space:]]+TABLE[[:space:]]*(IF[[:space:]]+NOT[[:space:]]+EXISTS[[:space:]]+)?[[:space:]]*"?([^"[:space:]]+)"?.*/\2/i' | head -n 1) if [[ -z "$table_name" ]]; then log_error "Could not extract table name from SQL file" return 1 fi log_verbose "Found table: $table_name" # Reset arrays PARSED_COLUMNS=() PARSED_PRIMARY_KEY="" # Use a temp file to process line by line, preserving formatting local temp_file=$(mktemp) # Pre-process: remove comments and ensure each column definition is on one line grep -v '^[[:space:]]*--' "$sql_file" | sed ':a;N;$!ba;s/\n[[:space:]]*,/ /g' > "$temp_file" # Extract columns while IFS= read -r line; do # Remove leading/trailing spaces local trimmed=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') # Skip empty lines, brackets, or constraints if [[ -z "$trimmed" ]] || [[ "$trimmed" == ")" ]] || [[ "$trimmed" == ");" ]] || [[ "$trimmed" =~ ^CREATE[[:space:]]+TABLE ]] || [[ "$trimmed" =~ ^CONSTRAINT ]] || [[ "$trimmed" =~ ^PRIMARY ]] || [[ "$trimmed" =~ ^FOREIGN ]] || [[ "$trimmed" =~ ^UNIQUE ]]; then continue fi # Extract column name (first word, remove quotes) local col_name=$(echo "$trimmed" | awk '{print $1}' | tr -d '"') # Extract column type (second word, remove size parameters like (100) and commas) local col_type=$(echo "$trimmed" | awk '{print $2}' | sed -E 's/\([0-9,]+\)//g' | tr -d ',' | tr '[:upper:]' '[:lower:]') # Check if nullable local nullable="true" if [[ "$trimmed" =~ NOT[[:space:]]+NULL ]]; then nullable="false" fi PARSED_COLUMNS+=("${col_name}|${col_type}|${nullable}") log_verbose "Found column: $col_name ($col_type, nullable: $nullable)" done < "$temp_file" # Extract primary key local pk_line=$(grep -i "PRIMARY KEY" "$sql_file") if [[ -n "$pk_line" ]]; then PARSED_PRIMARY_KEY=$(echo "$pk_line" | sed -E 's/.*PRIMARY[[:space:]]+KEY[[:space:]]*\(([^)]+)\).*/\1/' | tr -d '"') log_verbose "Found Primary Key: $PARSED_PRIMARY_KEY" fi # Clean up temp file rm -f "$temp_file" # Store in global variables for later use PARSED_TABLE_NAME="$table_name" export HAS_CREATED_AT=false export HAS_UPDATED_AT=false export HAS_DELETED_AT=false export HAS_ACTIVE=false export GO_PK_NAME="Id" export DB_PK_NAME="id" export GO_PK_TYPE="int64" export PROTO_PK_TYPE="int64" local pk_col_type="integer" for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" local lower_col=$(to_lower_case "$col_name") if [[ "$lower_col" == "created_at" || "$lower_col" == "createdat" ]]; then HAS_CREATED_AT=true; fi if [[ "$lower_col" == "updated_at" || "$lower_col" == "updatedat" ]]; then HAS_UPDATED_AT=true; fi if [[ "$lower_col" == "deleted_at" || "$lower_col" == "deletedat" ]]; then HAS_DELETED_AT=true; fi if [[ "$lower_col" == "active" ]]; then HAS_ACTIVE=true; fi if [[ "$col_name" == "$PARSED_PRIMARY_KEY" || (-z "$PARSED_PRIMARY_KEY" && "$lower_col" == "id") ]]; then PARSED_PRIMARY_KEY="$col_name" pk_col_type="$col_type" fi done if [[ -z "$PARSED_PRIMARY_KEY" && ${#PARSED_COLUMNS[@]} -gt 0 ]]; then IFS='|' read -r col_name col_type _ <<< "${PARSED_COLUMNS[0]}" PARSED_PRIMARY_KEY="$col_name" pk_col_type="$col_type" fi GO_PK_NAME=$(to_pascal_case "$PARSED_PRIMARY_KEY") DB_PK_NAME="$PARSED_PRIMARY_KEY" GO_PK_TYPE=$(sql_to_go_type "$pk_col_type" "false") PROTO_PK_TYPE=$(sql_to_proto_type "$pk_col_type" "false") log_success "Successfully parsed table: $table_name" } # Extract table info from JSON payload parse_json_payload() { local json_file="$1" log_info "Parsing JSON payload from: $json_file" if ! command -v jq &> /dev/null; then log_error "'jq' is required to parse JSON. Please install it (e.g., sudo apt-get install jq)." exit 1 fi local table_name="$TABLE_NAME" if [[ -z "$table_name" ]]; then table_name=$(basename "$json_file" | sed 's/\.[^.]*$//' | tr '-' '_') log_warning "Table name not provided, inferring from filename: $table_name" fi PARSED_COLUMNS=() PARSED_PRIMARY_KEY="" local temp_file=$(mktemp) # Analyze JSON structure using jq. It handles both array of objects and single object. jq -r ' (if type == "array" then .[0] else . end) | to_entries | .[] | .key as $k | (.value | type) as $t | if $t == "string" then if (.value | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")) then "\($k)|timestamp|true" else "\($k)|varchar|true" end elif $t == "number" then if (.value | tostring | test("\\.")) then "\($k)|float|true" else "\($k)|integer|true" end elif $t == "boolean" then "\($k)|boolean|true" elif $t == "object" or $t == "array" then "\($k)|jsonb|true" else "\($k)|varchar|true" end ' "$json_file" > "$temp_file" while IFS='|' read -r col_name col_type nullable; do if [[ -z "$col_name" ]]; then continue; fi PARSED_COLUMNS+=("${col_name}|${col_type}|${nullable}") log_verbose "Found property: $col_name ($col_type)" done < "$temp_file" rm -f "$temp_file" if [[ ${#PARSED_COLUMNS[@]} -eq 0 ]]; then log_error "No properties found in JSON file. Is it empty?" exit 1 fi PARSED_TABLE_NAME="$table_name" export HAS_CREATED_AT=false export HAS_UPDATED_AT=false export HAS_DELETED_AT=false export HAS_ACTIVE=false export GO_PK_NAME="Id" export DB_PK_NAME="id" local pk_col_type="varchar" # Default fallback for JSON for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" local lower_col=$(to_lower_case "$col_name") if [[ "$lower_col" == "created_at" || "$lower_col" == "createdat" ]]; then HAS_CREATED_AT=true; fi if [[ "$lower_col" == "updated_at" || "$lower_col" == "updatedat" ]]; then HAS_UPDATED_AT=true; fi if [[ "$lower_col" == "deleted_at" || "$lower_col" == "deletedat" ]]; then HAS_DELETED_AT=true; fi if [[ "$lower_col" == "active" ]]; then HAS_ACTIVE=true; fi if [[ -z "$PARSED_PRIMARY_KEY" ]]; then if [[ "$lower_col" == "id" || "$lower_col" == "${table_name}_id" || "$lower_col" == "kode" || "$lower_col" == "uuid" ]]; then PARSED_PRIMARY_KEY="$col_name" pk_col_type="$col_type" fi fi done if [[ -z "$PARSED_PRIMARY_KEY" ]]; then IFS='|' read -r col_name col_type _ <<< "${PARSED_COLUMNS[0]}" PARSED_PRIMARY_KEY="$col_name" pk_col_type="$col_type" fi GO_PK_NAME=$(to_pascal_case "$PARSED_PRIMARY_KEY") DB_PK_NAME="$PARSED_PRIMARY_KEY" GO_PK_TYPE=$(sql_to_go_type "$pk_col_type" "false") PROTO_PK_TYPE=$(sql_to_proto_type "$pk_col_type" "false") log_success "Successfully parsed JSON for table: $table_name" } # Generate domain files generate_domain_files() { local table_name="$1" local clean_name=$(to_pascal_case "$table_name") local target_dir="${INTERNAL_DIR}/${CUSTOM_DIR}" local package_name=$(extract_package_name "$CUSTOM_DIR") log_info "Generating domain files in: $target_dir with package name: $package_name" mkdir -p "$target_dir" local entity_file="${target_dir}/entity.go" if [ -f "$entity_file" ]; then log_warning "File already exists, skipping: $entity_file" else log_info "Generating file: $entity_file" cat > "$entity_file" << EOF package ${package_name} import ( "time" ) // ${clean_name} entity represents the ${table_name} table in the database type ${clean_name} struct { ${GO_PK_NAME} ${GO_PK_TYPE} \`json:"${DB_PK_NAME}" db:"${DB_PK_NAME}"\` DeletedAt *time.Time \`json:"deleted_at" db:"deleted_at"\` CreatedAt *time.Time \`json:"created_at" db:"created_at"\` UpdatedAt *time.Time \`json:"updated_at" db:"updated_at"\` EOF # Tambahkan kolom-kolom dari SQL (kecuali Id, CreatedAt, UpdatedAt, DeletedAt) for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" # Lewati kolom standar yang sudah kita definisikan manual if [[ "$(to_lower_case "$col_name")" == "id" || "$(to_lower_case "$col_name")" == "created_at" || "$(to_lower_case "$col_name")" == "updated_at" || "$(to_lower_case "$col_name")" == "deleted_at" || "$col_name" == "${GO_PK_NAME}" || "$col_name" == "${DB_PK_NAME}" || "$col_name" == "CreatedAt" || "$col_name" == "UpdatedAt" || "$col_name" == "DeletedAt" ]]; then continue fi go_type=$(sql_to_go_type "$col_type" "$nullable") go_field_name=$(to_pascal_case "$col_name") db_tag=$(to_lower_case "$col_name") # Nama kolom di DB (Lower case) json_tag=$(to_lower_case "$col_name") # Nama kolom di JSON (camelCase) echo " ${go_field_name} ${go_type} \`json:\"${json_tag}\" db:\"${db_tag}\"\`" >> "$entity_file" done cat >> "$entity_file" << EOF } // TableName specifies the table name for ${clean_name} func (${clean_name}) TableName() string { return "${table_name}" } EOF fi # --- Generate dto.go --- local dto_file="${target_dir}/dto.go" if [ -f "$dto_file" ]; then log_warning "File already exists, skipping: $dto_file" else log_info "Generating file: $dto_file" local dto_imports="" for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type _ <<< "$col" if [[ "$col_type" == *"timestamp"* || "$col_type" == *"timestamptz"* || "$col_type" == *"date"* ]]; then dto_imports="import \"time\"" break fi done cat > "$dto_file" << EOF package ${package_name} ${dto_imports} // ${clean_name}Request represents the request payload for ${clean_name} type ${clean_name}Request struct { EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" if is_managed_field "$col_name"; then continue fi go_type=$(sql_to_go_type "$col_type" "$nullable") go_field_name=$(to_pascal_case "$col_name") json_tag=$(to_lower_case "$col_name") validate_tag="" if [[ "$nullable" == "false" ]]; then validate_tag=" validate:\"required\"" fi echo " ${go_field_name} ${go_type} \`json:\"${json_tag}\"${validate_tag}\`" >> "$dto_file" done cat >> "$dto_file" << EOF } // ${clean_name}Response represents the response payload for ${clean_name} type ${clean_name}Response struct { EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" go_type=$(sql_to_go_type "$col_type" "$nullable") go_field_name=$(to_pascal_case "$col_name") json_tag=$(to_lower_case "$col_name") echo " ${go_field_name} ${go_type} \`json:\"${json_tag}\"\`" >> "$dto_file" done echo "}" >> "$dto_file" fi # --- Generate mapper.go --- local mapper_file="${target_dir}/mapper.go" if [ -f "$mapper_file" ]; then log_warning "File already exists, skipping: $mapper_file" else log_info "Generating file: $mapper_file" cat > "$mapper_file" << EOF package ${package_name} // mapRequestToEntity converts ${clean_name}Request to *${clean_name} entity func mapRequestToEntity(req ${clean_name}Request) *${clean_name} { return &${clean_name}{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" if is_managed_field "$col_name"; then continue fi go_field_name=$(to_pascal_case "$col_name") echo " ${go_field_name}: req.${go_field_name}," >> "$mapper_file" done cat >> "$mapper_file" << EOF } } // mapEntityToResponse converts *${clean_name} entity to *${clean_name}Response DTO func mapEntityToResponse(e *${clean_name}) *${clean_name}Response { if e == nil { return nil } return &${clean_name}Response{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" go_field_name=$(to_pascal_case "$col_name") echo " ${go_field_name}: e.${go_field_name}," >> "$mapper_file" done cat >> "$mapper_file" << EOF } } EOF fi # --- Generate repository.go --- local repo_file="${target_dir}/repository.go" if [ -f "$repo_file" ]; then log_warning "File already exists, skipping: $repo_file" else log_info "Generating file: $repo_file" cat > "$repo_file" << EOF package ${package_name} import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "service/internal/infrastructure/database" "service/pkg/utils/query" "service/pkg/logger" "github.com/jmoiron/sqlx" "gorm.io/gorm" ) type CommandRepository interface { Create(ctx context.Context, entity *${clean_name}) error Update(ctx context.Context, entity *${clean_name}) error Delete(ctx context.Context, id ${GO_PK_TYPE}) error } type QueryRepository interface { FindAll(ctx context.Context, limit, offset int) ([]${clean_name}, int64, error) FindByID(ctx context.Context, id ${GO_PK_TYPE}) (*${clean_name}, error) Search(ctx context.Context, filters map[string]interface{}, sorts []query.SortField, limit, offset int) ([]${clean_name}, int64, error) } type repository struct { dbManager database.Service dbName string qb query.QueryBuilder dbType query.DBType allowedColumnsMap map[string]bool } func NewCommandRepository(dbManager database.Service, dbName string) CommandRepository { return NewRepository(dbManager, dbName) } func NewQueryRepository(dbManager database.Service, dbName string) QueryRepository { return NewRepository(dbManager, dbName) } func NewRepository(dbManager database.Service, dbName string) *repository { dbType := query.DBTypePostgreSQL allowedColumns := []string{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" echo " \"$(to_lower_case "$col_name")\"," >> "$repo_file" done cat >> "$repo_file" << EOF } qb := query.NewSQLQueryBuilder(dbType). SetSecurityOptions(true, 1000). SetQueryLogging(true). SetQueryTimeout(30). SetAllowedColumns(allowedColumns) allowedMap := make(map[string]bool) for _, col := range allowedColumns { allowedMap[col] = true } return &repository{dbManager: dbManager, dbName: dbName, qb: qb, dbType: dbType, allowedColumnsMap: allowedMap} } // isColumnAllowed memvalidasi apakah kolom diizinkan untuk digunakan func (r *repository) isColumnAllowed(column string) bool { return r.allowedColumnsMap[column] } // getWriteGormDB extracts *gorm.DB for Write/Command operations func (r *repository) getWriteGormDB() (*gorm.DB, error) { return r.dbManager.GetGormDB(r.dbName) } // getReadSQLXDB extracts *sqlx.DB from read replicas for Read/Query operations func (r *repository) getReadSQLXDB() (*sqlx.DB, error) { db, err := r.dbManager.GetReadDB(r.dbName) if err != nil { return nil, fmt.Errorf("failed to get read db: %w", err) } // Gunakan Unsafe() agar sqlx mengabaikan kolom hasil query yang tidak terdapat di dalam struct return sqlx.NewDb(db, "pgx").Unsafe(), nil // Gunakan pgx untuk PostgreSQL } EOF local default_sort_col="${DB_PK_NAME}" if [[ "$HAS_CREATED_AT" == "true" ]]; then default_sort_col="created_at"; fi cat >> "$repo_file" << EOF // FindAll fetches all ${clean_name} with pagination func (r *repository) FindAll(ctx context.Context, limit, offset int) ([]${clean_name}, int64, error) { db, err := r.getReadSQLXDB() if err != nil { return nil, 0, err } dq := query.DynamicQuery{ From: "${table_name}", EOF if [[ "$HAS_DELETED_AT" == "true" ]]; then echo " Filters: []query.FilterGroup{{ Filters: []query.DynamicFilter{query.CreateFilter(\"deleted_at\", query.OpNull, nil)} }}," >> "$repo_file" fi cat >> "$repo_file" << EOF Limit: limit, Offset: offset, Sort: []query.SortField{query.CreateAscSort("${DB_PK_NAME}")}, } var results []${clean_name} if err := r.qb.ExecuteQuery(ctx, db, dq, &results); err != nil { return nil, 0, fmt.Errorf("failed to execute find all query: %w", err) } count, err := r.qb.ExecuteCount(ctx, db, dq) if err != nil { return nil, 0, fmt.Errorf("failed to execute count query: %w", err) } return results, count, nil } // FindByID fetches a single ${clean_name} by ID func (r *repository) FindByID(ctx context.Context, id ${GO_PK_TYPE}) (*${clean_name}, error) { db, err := r.getReadSQLXDB() if err != nil { return nil, err } var result ${clean_name} q := query.DynamicQuery{ From: "${table_name}", Filters: []query.FilterGroup{{ Filters: []query.DynamicFilter{ query.CreateEqualFilter("${DB_PK_NAME}", id), EOF if [[ "$HAS_DELETED_AT" == "true" ]]; then echo " query.CreateFilter(\"deleted_at\", query.OpNull, nil)," >> "$repo_file" fi cat >> "$repo_file" << EOF }, }}, Limit: 1, } if err := r.qb.ExecuteQueryRow(ctx, db, q, &result); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil // Return nil, nil jika data tidak ada, jangan lempar error query } // Wrapping error origin return nil, fmt.Errorf("failed to fetch ${clean_name}: %w", err) } return &result, nil } // Search fetches ${clean_name} based on dynamic filters and sorting func (r *repository) Search(ctx context.Context, filters map[string]interface{}, sorts []query.SortField, limit, offset int) ([]${clean_name}, int64, error) { db, err := r.getReadSQLXDB() if err != nil { return nil, 0, err } var dynamicFilters []query.DynamicFilter for k, v := range filters { colName := strings.ToLower(k) if !r.isColumnAllowed(colName) { continue } switch val := v.(type) { case string: if val != "" { EOF # Check text columns to use OpILike text_cols=() for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type _ <<< "$col" if [[ "$col_type" == *"varchar"* || "$col_type" == *"text"* || "$col_type" == *"char"* ]]; then text_cols+=("colName == \"$(to_lower_case "$col_name")\"") fi done if [ ${#text_cols[@]} -gt 0 ]; then text_cond=$(printf " || %s" "${text_cols[@]}") text_cond=${text_cond:4} cat >> "$repo_file" << EOF if $text_cond { dynamicFilters = append(dynamicFilters, query.CreateFilter(colName, query.OpILike, "%"+val+"%")) } else { if boolVal, err := strconv.ParseBool(val); err == nil { dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, boolVal)) } else { dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, val)) } } EOF else cat >> "$repo_file" << EOF if boolVal, err := strconv.ParseBool(val); err == nil { dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, boolVal)) } else { dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, val)) } EOF fi cat >> "$repo_file" << EOF } default: dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, val)) } } EOF if [[ "$HAS_DELETED_AT" == "true" ]]; then echo " dynamicFilters = append(dynamicFilters, query.CreateFilter(\"deleted_at\", query.OpNull, nil))" >> "$repo_file" fi cat >> "$repo_file" << EOF var sortFields []query.SortField for _, sort := range sorts { colName := strings.ToLower(sort.Column) if r.isColumnAllowed(colName) { sortFields = append(sortFields, query.SortField{ Column: colName, Order: sort.Order, }) } } // Jika tidak ada sort yang valid, gunakan default if len(sortFields) == 0 { sortFields = []query.SortField{query.CreateAscSort("${DB_PK_NAME}")} } q := query.DynamicQuery{ From: "${table_name}", Fields: []query.SelectField{ {Expression: "*"}, }, Filters: []query.FilterGroup{{Filters: dynamicFilters}}, Limit: limit, Offset: offset, Sort: sortFields, } logger.Default().Info("Built search query", logger.String("request", fmt.Sprintf("%+v", q))) var results []${clean_name} if err := r.qb.ExecuteQuery(ctx, db, q, &results); err != nil { return nil, 0, fmt.Errorf("failed to execute search query: %w", err) } count, err := r.qb.ExecuteCount(ctx, db, q) if err != nil { return nil, 0, fmt.Errorf("failed to execute search count query: %w", err) } return results, count, nil } // Create inserts a new ${clean_name} record func (r *repository) Create(ctx context.Context, entity *${clean_name}) error { db, err := r.getWriteGormDB() if err != nil { return err } return db.WithContext(ctx).Table(entity.TableName()).Create(entity).Error } func (r *repository) Update(ctx context.Context, entity *${clean_name}) error { db, err := r.getWriteGormDB() if err != nil { return err } return db.WithContext(ctx).Table(entity.TableName()).Save(entity).Error } func (r *repository) Delete(ctx context.Context, id ${GO_PK_TYPE}) error { db, err := r.getWriteGormDB() if err != nil { return err } return db.WithContext(ctx).Delete(&${clean_name}{}, id).Error } EOF fi # --- Generate service.go --- local service_file="${target_dir}/service.go" if [ -f "$service_file" ]; then log_warning "File already exists, skipping: $service_file" else log_info "Generating file: $service_file" cat > "$service_file" << EOF package ${package_name} import ( "context" "service/pkg/utils/query" "strings" "service/pkg/errors" "gorm.io/gorm" ) type Service interface { GetList(ctx context.Context, page, pageSize int, sorts []string) (map[string]interface{}, error) GetDetail(ctx context.Context, id ${GO_PK_TYPE}) (*${clean_name}Response, error) Search(ctx context.Context, filters map[string]interface{}, sorts []string, page, pageSize int) (map[string]interface{}, error) Create(ctx context.Context, req ${clean_name}Request) (*${clean_name}Response, error) Update(ctx context.Context, id ${GO_PK_TYPE}, req ${clean_name}Request) (*${clean_name}Response, error) Delete(ctx context.Context, id ${GO_PK_TYPE}) error } type service struct { cmdRepo CommandRepository queryRepo QueryRepository } func NewService(cmdRepo CommandRepository, queryRepo QueryRepository) Service { return &service{cmdRepo: cmdRepo, queryRepo: queryRepo} } func (s *service) GetList(ctx context.Context, page, pageSize int, sorts []string) (map[string]interface{}, error) { return s.Search(ctx, map[string]interface{}{}, sorts, page, pageSize) } EOF local pk_val_check="id <= 0" if [[ "$GO_PK_TYPE" == "string" ]]; then pk_val_check="id == \"\""; fi cat >> "$service_file" << EOF func (s *service) GetDetail(ctx context.Context, id ${GO_PK_TYPE}) (*${clean_name}Response, error) { if ${pk_val_check} { return nil, errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build() } entity, err := s.queryRepo.FindByID(ctx, id) if err != nil { return nil, errors.InternalError().Message("Failed to retrieve ${clean_name} detail").Cause(err).Build() } if entity == nil { return nil, errors.NotFoundError().Message("${clean_name} not found").Metadata("id", id).Build() } return mapEntityToResponse(entity), nil } func (s *service) Search(ctx context.Context, filters map[string]interface{}, sorts []string, page, pageSize int) (map[string]interface{}, error) { if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 10 } offset := (page - 1) * pageSize // --- 1. Implementasi Caching (Check) --- filterBytes, _ := json.Marshal(filters) sortBytes, _ := json.Marshal(sorts) hashInput := fmt.Sprintf("%s|%s|%d|%d", string(filterBytes), string(sortBytes), page, pageSize) hash := sha256.Sum256([]byte(hashInput)) cacheKey := fmt.Sprintf("${snake_case_name}_search_v2:%s", hex.EncodeToString(hash[:])) if s.cache != nil { var strData string if err := s.cache.Get(ctx, cacheKey, &strData); err == nil && strData != "" { var cachedData struct { Data []*${clean_name}Response \`json:"data"\` Total int64 \`json:"total"\` Page int \`json:"page"\` PageSize int \`json:"page_size"\` } if err := json.Unmarshal([]byte(strData), &cachedData); err == nil { logger.Default().Debug("Cache hit for ${clean_name} Search", logger.String("key", cacheKey)) return map[string]interface{}{ "data": cachedData.Data, "total": cachedData.Total, "page": cachedData.Page, "page_size": cachedData.PageSize, }, nil } } } // Konversi sorts string ke SortField var sortFields []query.SortField for _, sort := range sorts { if sort == "" { continue } // Default ASC, tandai dengan - untuk DESC order := "ASC" column := sort if strings.HasPrefix(sort, "-") { order = "DESC" column = strings.TrimPrefix(sort, "-") } else if strings.HasPrefix(sort, "+") { column = strings.TrimPrefix(sort, "+") } // Validasi kolom yang diizinkan (lowercase untuk mapping) allowedColumns := map[string]bool{ EOF # Add allowed columns for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" lower_name=$(to_lower_case "$col_name") echo " \"${lower_name}\": true," >> "$service_file" done cat >> "$service_file" << EOF } if allowedColumns[column] { sortFields = append(sortFields, query.SortField{ Column: column, Order: order, }) } } // Jika tidak ada sort yang valid, gunakan default if len(sortFields) == 0 { sortFields = []query.SortField{query.CreateAscSort("${DB_PK_NAME}")} } entities, total, err := s.queryRepo.Search(ctx, filters, sortFields, pageSize, offset) if err != nil { return nil, errors.InternalError().Message("Failed to search ${clean_name}s").Cause(err).Build() } responses := make([]*${clean_name}Response, len(entities)) for i, entity := range entities { responses[i] = mapEntityToResponse(&entity) } responseMap := map[string]interface{}{ "data": responses, "total": total, "page": page, "page_size": pageSize, } // --- 2. Implementasi Caching (Set) --- if s.cache != nil { if bytes, err := json.Marshal(responseMap); err == nil { _ = s.cache.Set(ctx, cacheKey, string(bytes), 5*time.Minute) } } return responseMap, nil } func (s *service) Create(ctx context.Context, req ${clean_name}Request) (*${clean_name}Response, error) { // TODO: Add validation here if needed entity := mapRequestToEntity(req) if err := s.cmdRepo.Create(ctx, entity); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, errors.AlreadyExistsError().Message("${clean_name} with this identifier already exists").Cause(err).Build() } return nil, errors.InternalError().Message("Failed to create ${clean_name}").Cause(err).Build() } // Re-fetch to get the complete created entity createdEntity, err := s.queryRepo.FindByID(ctx, entity.${GO_PK_NAME}) if err != nil { return nil, errors.InternalError().Message("Failed to retrieve newly created ${clean_name}").Cause(err).Build() } if s.cache != nil { _ = s.cache.Delete(ctx, "${snake_case_name}_search_v2:*") } return mapEntityToResponse(createdEntity), nil } func (s *service) Update(ctx context.Context, id ${GO_PK_TYPE}, req ${clean_name}Request) (*${clean_name}Response, error) { if ${pk_val_check} { return nil, errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build() } // Cek apakah record ada existing, err := s.queryRepo.FindByID(ctx, id) if err != nil { return nil, errors.InternalError().Message("Failed to retrieve ${clean_name}").Cause(err).Build() } if existing == nil { return nil, errors.NotFoundError().Message("${clean_name} not found").Metadata("id", id).Build() } // Update fields from request on the existing entity EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" if is_managed_field "$col_name"; then continue fi go_field_name=$(to_pascal_case "$col_name") echo " existing.${go_field_name} = req.${go_field_name}" >> "$service_file" done cat >> "$service_file" << EOF if err := s.cmdRepo.Update(ctx, existing); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, errors.AlreadyExistsError().Message("${clean_name} with this identifier already exists").Cause(err).Build() } return nil, errors.InternalError().Message("Failed to update ${clean_name}").Cause(err).Build() } if s.cache != nil { _ = s.cache.Delete(ctx, "${snake_case_name}_search_v2:*") } return mapEntityToResponse(existing), nil } func (s *service) Delete(ctx context.Context, id ${GO_PK_TYPE}) error { if ${pk_val_check} { return errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build() } // Cek apakah record ada sebelum dihapus untuk idempotency dan pre-delete logic existing, err := s.queryRepo.FindByID(ctx, id) if err != nil { return errors.InternalError().Message("Failed to retrieve ${clean_name} before deletion").Cause(err).Build() } if existing == nil { return nil } // Idempotent: jika tidak ada, anggap berhasil if err := s.cmdRepo.Delete(ctx, id); err != nil { return errors.InternalError().Message("Failed to delete ${clean_name}").Cause(err).Build() } if s.cache != nil { _ = s.cache.Delete(ctx, "${snake_case_name}_search_v2:*") } return nil } EOF fi # --- Generate service_test.go (Boilerplate Unit Test & Mocks) --- local test_file="${target_dir}/service_test.go" if [ -f "$test_file" ]; then log_warning "File already exists, skipping: $test_file" else log_info "Generating unit test and mock files..." cat > "$test_file" << EOF package ${package_name} import ( "context" "testing" "service/pkg/utils/query" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // --- Mock Command Repository --- type MockCommandRepository struct { mock.Mock } func (m *MockCommandRepository) Create(ctx context.Context, entity *${clean_name}) error { args := m.Called(ctx, entity) return args.Error(0) } func (m *MockCommandRepository) Update(ctx context.Context, entity *${clean_name}) error { args := m.Called(ctx, entity) return args.Error(0) } func (m *MockCommandRepository) Delete(ctx context.Context, id ${GO_PK_TYPE}) error { args := m.Called(ctx, id) return args.Error(0) } // --- Mock Query Repository --- type MockQueryRepository struct { mock.Mock } func (m *MockQueryRepository) FindAll(ctx context.Context, limit, offset int) ([]${clean_name}, int64, error) { args := m.Called(ctx, limit, offset) return args.Get(0).([]${clean_name}), args.Get(1).(int64), args.Error(2) } func (m *MockQueryRepository) FindByID(ctx context.Context, id ${GO_PK_TYPE}) (*${clean_name}, error) { args := m.Called(ctx, id) if args.Get(0) != nil { return args.Get(0).(*${clean_name}), args.Error(1) } return nil, args.Error(1) } func (m *MockQueryRepository) Search(ctx context.Context, filters map[string]interface{}, sorts []query.SortField, limit, offset int) ([]${clean_name}, int64, error) { args := m.Called(ctx, filters, sorts, limit, offset) return args.Get(0).([]${clean_name}), args.Get(1).(int64), args.Error(2) } // --- Test Suites --- func TestGetDetail_Success(t *testing.T) { mockCmdRepo := new(MockCommandRepository) mockQueryRepo := new(MockQueryRepository) svc := NewService(mockCmdRepo, mockQueryRepo, nil) var dummyId ${GO_PK_TYPE} var notFoundId ${GO_PK_TYPE} expectedData := &${clean_name}{ ${GO_PK_NAME}: dummyId, } mockQueryRepo.On("FindByID", mock.Anything, dummyId).Return(expectedData, nil) result, err := svc.GetDetail(context.Background(), dummyId) assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, dummyId, result.${GO_PK_NAME}) mockQueryRepo.AssertExpectations(t) } func TestGetDetail_NotFound(t *testing.T) { mockCmdRepo := new(MockCommandRepository) mockQueryRepo := new(MockQueryRepository) svc := NewService(mockCmdRepo, mockQueryRepo, nil) mockQueryRepo.On("FindByID", mock.Anything, notFoundId).Return(nil, nil) result, err := svc.GetDetail(context.Background(), notFoundId) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "not found") mockQueryRepo.AssertExpectations(t) } func TestGetDetail_InvalidID(t *testing.T) { mockCmdRepo := new(MockCommandRepository) mockQueryRepo := new(MockQueryRepository) svc := NewService(mockCmdRepo, mockQueryRepo, nil) mockQueryRepo.AssertNotCalled(t, "FindByID") } EOF fi log_success "Generated domain files for $table_name" } # Generate handler file generate_handler_file() { local table_name="$1" local clean_name=$(to_pascal_case "$table_name") # Misal: "Language" local package_name=$(extract_package_name "$CUSTOM_DIR") # Misal: "reference" local target_dir="${INTERNAL_DIR}/infrastructure/transport/http/handlers/${CUSTOM_DIR}" log_info "Generating handler file in: $target_dir" mkdir -p "$target_dir" # Konversi 'clean_name' (PascalCase) ke 'snake_case' untuk nama file local snake_case_name=$(to_snake_case "$clean_name") local handler_file_name="${target_dir}/${package_name}_handler.go" if [ -f "$handler_file_name" ]; then log_warning "File already exists, skipping: $handler_file_name" return fi local id_parse_snippet="" if [[ "$GO_PK_TYPE" == "string" ]]; then id_parse_snippet="id := c.Param(\"id\")" else id_parse_snippet="id, err := strconv.ParseInt(c.Param(\"id\"), 10, 64) if err != nil { response.Error(c, http.StatusBadRequest, \"Invalid ID format\", nil) return }" fi log_info "Generating file: $handler_file_name" cat > "$handler_file_name" << EOF package handlers import ( "fmt" "math" "net/http" "strconv" "strings" ${package_name}Service "service/internal/${CUSTOM_DIR}" "service/pkg/errors" "service/pkg/logger" "service/pkg/response" "github.com/gin-gonic/gin" ) type ${clean_name}Handler struct { service ${package_name}Service.Service } func New${clean_name}Handler(service ${package_name}Service.Service) *${clean_name}Handler { return &${clean_name}Handler{service: service} } func (h *${clean_name}Handler) RegisterRoutes(router *gin.RouterGroup) { group := router.Group("/${snake_case_name}s") { group.GET("", h.GetList) group.GET("/search", h.Search) group.GET("/:id", h.GetDetail) group.POST("", h.Create) group.PUT("/:id", h.Update) group.DELETE("/:id", h.Delete) } } // GetList godoc // @Summary Get list of ${clean_name}s // @Description Retrieve a paginated list of ${clean_name} // @Tags ${snake_case_name}s // @Produce json // @Param page query int false "Page number" default(1) // @Param page_size query int false "Number of items per page" default(10) // @Param sort query string false "Sort fields (e.g. +name,-created_at)" ${active_param_doc} // @Success 200 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s [get] func (h *${clean_name}Handler) GetList(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) ${active_parsing} // Parse parameter sort (format: sort=column1,-column2,+column3) // -column untuk DESC, +column atau column untuk ASC var sorts []string if sortParam := c.Query("sort"); sortParam != "" { sorts = strings.Split(sortParam, ",") // Validasi dan bersihkan sort parameters for i, sort := range sorts { sorts[i] = strings.TrimSpace(sort) } } ctx := c.Request.Context() ${get_list_call} if err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } data := result["data"] total := result["total"].(int64) totalPages := int(math.Ceil(float64(total) / float64(pageSize))) meta := response.Meta{Page: page, Limit: pageSize, Total: int(total), TotalPages: totalPages} response.Paginated(c, http.StatusOK, "Successfully retrieved ${clean_name} list", data, meta) } // GetDetail godoc // @Summary Get ${clean_name} detail // @Description Retrieve detailed information about a specific ${clean_name} // @Tags ${snake_case_name}s // @Produce json // @Param id path int true "${clean_name} ID" // @Success 200 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s/{id} [get] func (h *${clean_name}Handler) GetDetail(c *gin.Context) { ${id_parse_snippet} ctx := c.Request.Context() result, err := h.service.GetDetail(ctx, id) if err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } response.Success(c, http.StatusOK, "Successfully retrieved ${clean_name} detail", result) } // Search godoc // @Summary Search ${clean_name}s // @Description Search ${clean_name} records using dynamic filters // @Tags ${snake_case_name}s // @Produce json // @Param page query int false "Page number" default(1) // @Param limit query int false "Limit per page" default(10) // @Success 200 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s/search [get] func (h *${clean_name}Handler) Search(c *gin.Context) { // Parse Query Params dengan default value page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) // Ambil parameter filter // Ambil parameter filter secara dinamis filters := make(map[string]interface{}) for key, values := range c.Request.URL.Query() { if key == "page" || key == "limit" || key == "page_size" || key == "sort" { continue } if len(values) > 0 && values[0] != "" { filters[key] = values[0] } } EOF cat >> "$handler_file_name" << EOF // Parse parameter sort (format: sort=column1,-column2,+column3) // -column untuk DESC, +column atau column untuk ASC var sorts []string if sortParam := c.Query("sort"); sortParam != "" { sorts = strings.Split(sortParam, ",") // Validasi dan bersihkan sort parameters for i, sort := range sorts { sorts[i] = strings.TrimSpace(sort) } } logger.Default().Info("Search request", logger.String("filters", fmt.Sprintf("%v", filters)), logger.String("sorts", fmt.Sprintf("%v", sorts)), logger.Int("page", page), logger.Int("limit", limit)) ctx := c.Request.Context() // Panggil service dengan parameter sort tambahan result, err := h.service.Search(ctx, filters, sorts, page, limit) if err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } // Extract data dari map service untuk response format data := result["data"] total := result["total"].(int64) // Hitung total pages totalPages := 0 if limit > 0 { totalPages = int(math.Ceil(float64(total) / float64(limit))) } meta := response.Meta{ Page: page, Limit: limit, Total: int(total), TotalPages: totalPages, } response.Paginated(c, http.StatusOK, "Successfully retrieved ${clean_name} search results", data, meta) } // Create godoc // @Summary Create new ${clean_name} // @Description Create a new ${clean_name} record // @Tags ${snake_case_name}s // @Accept json // @Produce json // @Param request body ${package_name}Service.${clean_name}Request true "Payload" // @Success 201 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s [post] func (h *${clean_name}Handler) Create(c *gin.Context) { var req ${package_name}Service.${clean_name}Request if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error()) return } ctx := c.Request.Context() created, err := h.service.Create(ctx, req) if err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } response.Success(c, http.StatusCreated, "Successfully created ${clean_name}", created) } // Update godoc // @Summary Update an existing ${clean_name} // @Description Update details of an existing ${clean_name} record by ID // @Tags ${snake_case_name}s // @Accept json // @Produce json // @Param id path int true "${clean_name} ID" // @Param request body ${package_name}Service.${clean_name}Request true "Payload" // @Success 200 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s/{id} [put] func (h *${clean_name}Handler) Update(c *gin.Context) { ${id_parse_snippet} var req ${package_name}Service.${clean_name}Request if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, "Invalid request body", err.Error()) return } ctx := c.Request.Context() updated, err := h.service.Update(ctx, id, req) if err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } response.Success(c, http.StatusOK, "Successfully updated ${clean_name}", updated) } // Delete godoc // @Summary Delete a ${clean_name} // @Description Delete a ${clean_name} record by ID (soft delete) // @Tags ${snake_case_name}s // @Produce json // @Param id path int true "${clean_name} ID" // @Success 200 {object} response.Response // @Security BearerAuth // @Router /${snake_case_name}s/{id} [delete] func (h *${clean_name}Handler) Delete(c *gin.Context) { ${id_parse_snippet} ctx := c.Request.Context() if err := h.service.Delete(ctx, id); err != nil { appErr := errors.FromError(err) response.Error(c, appErr.HTTPStatus(), appErr.Error(), appErr.Metadata()) return } response.Success(c, http.StatusOK, "Successfully deleted ${clean_name}", nil) } EOF log_success "Generated handler file: ${snake_case_name}_handler.go" } # Generate gRPC proto file generate_proto_file() { local table_name="$1" local clean_name=$(to_pascal_case "$table_name") # e.g., Page local package_name=$(extract_package_name "$CUSTOM_DIR") # e.g., master # Create proto directory structure local proto_dir="${INTERNAL_DIR}/infrastructure/transport/grpc/proto/${CUSTOM_DIR}/v1" log_info "Generating proto file in: $proto_dir" mkdir -p "$proto_dir" local proto_file="${proto_dir}/${package_name}.proto" if [ -f "$proto_file" ]; then log_warning "File already exists, skipping: $proto_file" return fi log_info "Generating file: $proto_file" # Start writing the proto file cat > "$proto_file" << EOF syntax = "proto3"; package ${package_name}.v1; // Path should be relative to the module root. // The package name is specified after the semicolon. option go_package = "internal/infrastructure/transport/grpc/gen/${CUSTOM_DIR}/v1;${package_name}V1"; import "google/protobuf/wrappers.proto"; import "google/protobuf/timestamp.proto"; import "google/protobuf/empty.proto"; // Service definition for ${clean_name}. service ${clean_name}Service { // Get a single ${clean_name} by its ID. rpc Get${clean_name}(Get${clean_name}Request) returns (${clean_name}Response); // Get a list of ${clean_name}s with pagination and filtering. rpc List${clean_name}s(List${clean_name}sRequest) returns (List${clean_name}sResponse); // Create a new ${clean_name}. rpc Create${clean_name}(Create${clean_name}Request) returns (${clean_name}Response); // Update an existing ${clean_name}. rpc Update${clean_name}(Update${clean_name}Request) returns (${clean_name}Response); // Delete a ${clean_name} by its ID. rpc Delete${clean_name}(Delete${clean_name}Request) returns (google.protobuf.Empty); } // The main message representing a ${clean_name}. message ${clean_name} { EOF # Add fields from parsed SQL local field_index=1 for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" # Convert column name to snake_case for proto convention local proto_field_name=$(to_snake_case "$col_name") local proto_type=$(sql_to_proto_type "$col_type" "$nullable") echo " ${proto_type} ${proto_field_name} = ${field_index};" >> "$proto_file" ((field_index++)) done cat >> "$proto_file" << EOF } // --- Request/Response Messages --- message Get${clean_name}Request { ${PROTO_PK_TYPE} id = 1; } message ${clean_name}Response { ${clean_name} data = 1; } message List${clean_name}sRequest { int32 page = 1; int32 page_size = 2; // Add filter fields here if needed } message List${clean_name}sResponse { repeated ${clean_name} data = 1; int64 total_items = 2; } message Create${clean_name}Request { EOF # Add fields for Create request local create_field_index=1 for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" # Skip auto-managed fields if [[ "$col_name" == "Id" || "$col_name" == "CreatedAt" || "$col_name" == "UpdatedAt" || "$col_name" == "DeletedAt" ]]; then continue fi local proto_field_name=$(to_snake_case "$col_name") # For create, we don't use optional, we just use the base type local proto_type=$(sql_to_proto_type "$col_type" "false") # Treat as non-nullable for create echo " ${proto_type} ${proto_field_name} = ${create_field_index};" >> "$proto_file" ((create_field_index++)) done cat >> "$proto_file" << EOF } message Update${clean_name}Request { int64 id = 1; EOF # Add fields for Update request, all should be optional local update_field_index=2 for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name col_type nullable <<< "$col" if is_managed_field "$col_name"; then continue fi local proto_field_name=$(to_snake_case "$col_name") # For update, all fields are optional local proto_type=$(sql_to_proto_type "$col_type" "true") echo " ${proto_type} ${proto_field_name} = ${update_field_index};" >> "$proto_file" ((update_field_index++)) done cat >> "$proto_file" << EOF } message Delete${clean_name}Request { ${PROTO_PK_TYPE} id = 1; } EOF log_success "Generated proto file: ${package_name}.proto" log_info "Running proto.sh to generate Go code from this new .proto file..." local rel_proto_dir="internal/infrastructure/transport/grpc/proto/${CUSTOM_DIR}/v1" if [ -x "${SCRIPT_DIR}/proto.sh" ]; then "${SCRIPT_DIR}/proto.sh" "$rel_proto_dir" elif [ -f "${SCRIPT_DIR}/proto.sh" ]; then bash "${SCRIPT_DIR}/proto.sh" "$rel_proto_dir" else log_warning "proto.sh not found. Please run it manually." fi } # Generate gRPC handler and mapper file generate_grpc_handler() { local table_name="$1" local clean_name=$(to_pascal_case "$table_name") # e.g., Page local package_name=$(extract_package_name "$CUSTOM_DIR") # e.g., master local target_dir="${INTERNAL_DIR}/infrastructure/transport/grpc/handlers/${CUSTOM_DIR}" log_info "Generating gRPC handler in: $target_dir" mkdir -p "$target_dir" local handler_file_name="${target_dir}/${package_name}_grpc_handler.go" if [ -f "$handler_file_name" ]; then log_warning "File already exists, skipping: $handler_file_name" else log_info "Generating file: $handler_file_name" cat > "$handler_file_name" << EOF package handlers import ( "context" "fmt" gen${clean_name} "service/internal/infrastructure/transport/grpc/gen/${CUSTOM_DIR}/v1" ${package_name}Service "service/internal/${CUSTOM_DIR}" "service/pkg/errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" ) type ${clean_name}Handler struct { gen${clean_name}.Unimplemented${clean_name}ServiceServer service ${package_name}Service.Service } func New${clean_name}Handler(service ${package_name}Service.Service) *${clean_name}Handler { return &${clean_name}Handler{service: service} } func (h *${clean_name}Handler) Get${clean_name}(ctx context.Context, req *gen${clean_name}.Get${clean_name}Request) (*gen${clean_name}.${clean_name}Response, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "request cannot be nil") } data, err := h.service.GetDetail(ctx, req.GetId()) if err != nil { if errors.Is(err, errors.ErrNotFound) { return nil, status.Error(codes.NotFound, "${clean_name} not found") } return nil, status.Error(codes.Internal, fmt.Sprintf("failed to get ${clean_name}: %v", err)) } return &gen${clean_name}.${clean_name}Response{ Data: Map${clean_name}ResponseToProto(data), }, nil } func (h *${clean_name}Handler) List${clean_name}s(ctx context.Context, req *gen${clean_name}.List${clean_name}sRequest) (*gen${clean_name}.List${clean_name}sResponse, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "request cannot be nil") } page := int(req.GetPage()) pageSize := int(req.GetPageSize()) result, err := h.service.GetList(ctx, page, pageSize, nil) if err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to list ${clean_name}s: %v", err)) } data := result["data"].([]*${package_name}Service.${clean_name}Response) total := result["total"].(int64) protoData := make([]*gen${clean_name}.${clean_name}, len(data)) for i, e := range data { protoData[i] = Map${clean_name}ResponseToProto(e) } return &gen${clean_name}.List${clean_name}sResponse{ Data: protoData, TotalItems: total, }, nil } func (h *${clean_name}Handler) Create${clean_name}(ctx context.Context, req *gen${clean_name}.Create${clean_name}Request) (*gen${clean_name}.${clean_name}Response, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "request cannot be nil") } createReq := MapProtoTo${clean_name}Request(req) created, err := h.service.Create(ctx, createReq) if err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to create ${clean_name}: %v", err)) } return &gen${clean_name}.${clean_name}Response{ Data: Map${clean_name}ResponseToProto(created), }, nil } func (h *${clean_name}Handler) Update${clean_name}(ctx context.Context, req *gen${clean_name}.Update${clean_name}Request) (*gen${clean_name}.${clean_name}Response, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "request cannot be nil") } updateReq := MapProtoTo${clean_name}UpdateRequest(req) updated, err := h.service.Update(ctx, req.GetId(), updateReq) if err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to update ${clean_name}: %v", err)) } return &gen${clean_name}.${clean_name}Response{ Data: Map${clean_name}ResponseToProto(updated), }, nil } func (h *${clean_name}Handler) Delete${clean_name}(ctx context.Context, req *gen${clean_name}.Delete${clean_name}Request) (*emptypb.Empty, error) { if req == nil { return nil, status.Error(codes.InvalidArgument, "request cannot be nil") } if err := h.service.Delete(ctx, req.GetId()); err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("failed to delete ${clean_name}: %v", err)) } return &emptypb.Empty{}, nil } EOF log_success "Generated gRPC handler file: ${package_name}_grpc_handler.go" fi # Generate Mapper local mapper_file_name="${target_dir}/${package_name}_grpc_mapper.go" if [ -f "$mapper_file_name" ]; then log_warning "File already exists, skipping: $mapper_file_name" else log_info "Generating file: $mapper_file_name" cat > "$mapper_file_name" << EOF package handlers import ( gen${clean_name} "service/internal/infrastructure/transport/grpc/gen/${CUSTOM_DIR}/v1" ${package_name}Service "service/internal/${CUSTOM_DIR}" ) func Map${clean_name}ResponseToProto(e *${package_name}Service.${clean_name}Response) *gen${clean_name}.${clean_name} { if e == nil { return nil } return &gen${clean_name}.${clean_name}{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" go_field_name=$(to_pascal_case "$col_name") echo " ${go_field_name}: e.${go_field_name}," >> "$mapper_file_name" done cat >> "$mapper_file_name" << EOF } } func MapProtoTo${clean_name}Request(req *gen${clean_name}.Create${clean_name}Request) ${package_name}Service.${clean_name}Request { return ${package_name}Service.${clean_name}Request{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" if is_managed_field "$col_name"; then continue fi go_field_name=$(to_pascal_case "$col_name") echo " ${go_field_name}: req.${go_field_name}," >> "$mapper_file_name" done cat >> "$mapper_file_name" << EOF } } func MapProtoTo${clean_name}UpdateRequest(req *gen${clean_name}.Update${clean_name}Request) ${package_name}Service.${clean_name}Request { return ${package_name}Service.${clean_name}Request{ EOF for col in "${PARSED_COLUMNS[@]}"; do IFS='|' read -r col_name _ _ <<< "$col" if is_managed_field "$col_name"; then continue fi go_field_name=$(to_pascal_case "$col_name") echo " ${go_field_name}: req.${go_field_name}," >> "$mapper_file_name" done cat >> "$mapper_file_name" << EOF } } EOF log_success "Generated gRPC mapper file: ${package_name}_grpc_mapper.go" fi } # --- Main Execution --- # Helper to create output directory for docs setup_output_dir() { if [[ ! -d "$OUTPUT_DIR" ]]; then log_verbose "Creating output directory: $OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" fi } # Main execution function main() { log_header "Advanced Context Generator" parse_args "$@" setup_output_dir # Parse SQL file to get table info declare -a PARSED_COLUMNS declare PARSED_TABLE_NAME declare PARSED_PRIMARY_KEY if [[ -n "$SQL_FILE" ]]; then if ! parse_sql_table "$SQL_FILE"; then exit 1; fi elif [[ -n "$JSON_FILE" ]]; then if ! parse_json_payload "$JSON_FILE"; then exit 1; fi fi log_info "Generating for table: $PARSED_TABLE_NAME" log_verbose "Target directory: ${INTERNAL_DIR}/${CUSTOM_DIR}" log_verbose "Generate type: $GENERATE_TYPE" case "$GENERATE_TYPE" in "domain") generate_domain_files "$PARSED_TABLE_NAME" ;; "handler") generate_handler_file "$PARSED_TABLE_NAME" ;; "proto") generate_proto_file "$PARSED_TABLE_NAME" ;; "grpc") generate_grpc_handler "$PARSED_TABLE_NAME" ;; "all") generate_domain_files "$PARSED_TABLE_NAME" generate_handler_file "$PARSED_TABLE_NAME" generate_proto_file "$PARSED_TABLE_NAME" generate_grpc_handler "$PARSED_TABLE_NAME" ;; esac local package_name=$(extract_package_name "$CUSTOM_DIR") local clean_name=$(to_pascal_case "$PARSED_TABLE_NAME") echo -e "\n${PURPLE}================================================================${NC}" echo -e "${GREEN}✨ MODULE [${clean_name}] GENERATED SUCCESSFULLY! ✨${NC}" echo -e "${PURPLE}================================================================${NC}\n" echo -e "${YELLOW}🚀 NEXT STEPS TO ACTIVATE YOUR MODULE:${NC}\n" echo -e "${CYAN}1. Inisialisasi Repository & Service (misal di cmd/api/main.go):${NC}" echo -e " Pastikan Anda meng-import package: ${WHITE}service/internal/${CUSTOM_DIR}${NC}" echo -e " ${WHITE}${package_name}CmdRepo := ${package_name}.NewCommandRepository(dbService, \"default\")${NC}" echo -e " ${WHITE}${package_name}QueryRepo := ${package_name}.NewQueryRepository(dbService, \"default\")${NC}" echo -e " ${WHITE}${package_name}Svc := ${package_name}.NewService(${package_name}CmdRepo, ${package_name}QueryRepo, cacheManager)${NC}\n" if [[ "$GENERATE_TYPE" == "all" || "$GENERATE_TYPE" == "handler" ]]; then echo -e "${CYAN}2. Registrasi REST API Handler (ke Router Gin):${NC}" echo -e " Pastikan Anda meng-import package: ${WHITE}service/internal/infrastructure/transport/http/handlers/${CUSTOM_DIR}${NC}" echo -e " ${WHITE}${package_name}Handler := handlers.New${clean_name}Handler(${package_name}Svc)${NC}" echo -e " ${WHITE}${package_name}Handler.RegisterRoutes(apiGroup)${NC}\n" fi if [[ "$GENERATE_TYPE" == "all" || "$GENERATE_TYPE" == "grpc" || "$GENERATE_TYPE" == "proto" ]]; then echo -e "${CYAN}3. Registrasi gRPC Handler di server.go:${NC}" echo -e " Pastikan Anda meng-import package: ${WHITE}service/internal/infrastructure/transport/grpc/handlers/${CUSTOM_DIR}${NC}" echo -e " ${WHITE}grpc${clean_name}Handler := grpcHandlers.New${clean_name}Handler(${package_name}Svc)${NC}" echo -e " ${WHITE}gen${clean_name}.Register${clean_name}ServiceServer(srv, grpc${clean_name}Handler)${NC}\n" fi echo -e "${CYAN}4. Rapihkan Dependencies & Tinjau Kode:${NC}" echo -e " ${WHITE}go mod tidy${NC}\n" } # Run main function with all arguments main "$@"