| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853 |
- package repository
- import (
- "context"
- "database/sql"
- "encoding/json"
- "fmt"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- func (r *opsRepository) ListAlertRules(ctx context.Context) ([]*service.OpsAlertRule, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- q := `
- SELECT
- id,
- name,
- COALESCE(description, ''),
- enabled,
- COALESCE(severity, ''),
- metric_type,
- operator,
- threshold,
- window_minutes,
- sustained_minutes,
- cooldown_minutes,
- COALESCE(notify_email, true),
- filters,
- last_triggered_at,
- created_at,
- updated_at
- FROM ops_alert_rules
- ORDER BY id DESC`
- rows, err := r.db.QueryContext(ctx, q)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- out := []*service.OpsAlertRule{}
- for rows.Next() {
- var rule service.OpsAlertRule
- var filtersRaw []byte
- var lastTriggeredAt sql.NullTime
- if err := rows.Scan(
- &rule.ID,
- &rule.Name,
- &rule.Description,
- &rule.Enabled,
- &rule.Severity,
- &rule.MetricType,
- &rule.Operator,
- &rule.Threshold,
- &rule.WindowMinutes,
- &rule.SustainedMinutes,
- &rule.CooldownMinutes,
- &rule.NotifyEmail,
- &filtersRaw,
- &lastTriggeredAt,
- &rule.CreatedAt,
- &rule.UpdatedAt,
- ); err != nil {
- return nil, err
- }
- if lastTriggeredAt.Valid {
- v := lastTriggeredAt.Time
- rule.LastTriggeredAt = &v
- }
- if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
- var decoded map[string]any
- if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
- rule.Filters = decoded
- }
- }
- out = append(out, &rule)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return out, nil
- }
- func (r *opsRepository) CreateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if input == nil {
- return nil, fmt.Errorf("nil input")
- }
- filtersArg, err := opsNullJSONMap(input.Filters)
- if err != nil {
- return nil, err
- }
- q := `
- INSERT INTO ops_alert_rules (
- name,
- description,
- enabled,
- severity,
- metric_type,
- operator,
- threshold,
- window_minutes,
- sustained_minutes,
- cooldown_minutes,
- notify_email,
- filters,
- created_at,
- updated_at
- ) VALUES (
- $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW()
- )
- RETURNING
- id,
- name,
- COALESCE(description, ''),
- enabled,
- COALESCE(severity, ''),
- metric_type,
- operator,
- threshold,
- window_minutes,
- sustained_minutes,
- cooldown_minutes,
- COALESCE(notify_email, true),
- filters,
- last_triggered_at,
- created_at,
- updated_at`
- var out service.OpsAlertRule
- var filtersRaw []byte
- var lastTriggeredAt sql.NullTime
- if err := r.db.QueryRowContext(
- ctx,
- q,
- strings.TrimSpace(input.Name),
- strings.TrimSpace(input.Description),
- input.Enabled,
- strings.TrimSpace(input.Severity),
- strings.TrimSpace(input.MetricType),
- strings.TrimSpace(input.Operator),
- input.Threshold,
- input.WindowMinutes,
- input.SustainedMinutes,
- input.CooldownMinutes,
- input.NotifyEmail,
- filtersArg,
- ).Scan(
- &out.ID,
- &out.Name,
- &out.Description,
- &out.Enabled,
- &out.Severity,
- &out.MetricType,
- &out.Operator,
- &out.Threshold,
- &out.WindowMinutes,
- &out.SustainedMinutes,
- &out.CooldownMinutes,
- &out.NotifyEmail,
- &filtersRaw,
- &lastTriggeredAt,
- &out.CreatedAt,
- &out.UpdatedAt,
- ); err != nil {
- return nil, err
- }
- if lastTriggeredAt.Valid {
- v := lastTriggeredAt.Time
- out.LastTriggeredAt = &v
- }
- if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
- var decoded map[string]any
- if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
- out.Filters = decoded
- }
- }
- return &out, nil
- }
- func (r *opsRepository) UpdateAlertRule(ctx context.Context, input *service.OpsAlertRule) (*service.OpsAlertRule, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if input == nil {
- return nil, fmt.Errorf("nil input")
- }
- if input.ID <= 0 {
- return nil, fmt.Errorf("invalid id")
- }
- filtersArg, err := opsNullJSONMap(input.Filters)
- if err != nil {
- return nil, err
- }
- q := `
- UPDATE ops_alert_rules
- SET
- name = $2,
- description = $3,
- enabled = $4,
- severity = $5,
- metric_type = $6,
- operator = $7,
- threshold = $8,
- window_minutes = $9,
- sustained_minutes = $10,
- cooldown_minutes = $11,
- notify_email = $12,
- filters = $13,
- updated_at = NOW()
- WHERE id = $1
- RETURNING
- id,
- name,
- COALESCE(description, ''),
- enabled,
- COALESCE(severity, ''),
- metric_type,
- operator,
- threshold,
- window_minutes,
- sustained_minutes,
- cooldown_minutes,
- COALESCE(notify_email, true),
- filters,
- last_triggered_at,
- created_at,
- updated_at`
- var out service.OpsAlertRule
- var filtersRaw []byte
- var lastTriggeredAt sql.NullTime
- if err := r.db.QueryRowContext(
- ctx,
- q,
- input.ID,
- strings.TrimSpace(input.Name),
- strings.TrimSpace(input.Description),
- input.Enabled,
- strings.TrimSpace(input.Severity),
- strings.TrimSpace(input.MetricType),
- strings.TrimSpace(input.Operator),
- input.Threshold,
- input.WindowMinutes,
- input.SustainedMinutes,
- input.CooldownMinutes,
- input.NotifyEmail,
- filtersArg,
- ).Scan(
- &out.ID,
- &out.Name,
- &out.Description,
- &out.Enabled,
- &out.Severity,
- &out.MetricType,
- &out.Operator,
- &out.Threshold,
- &out.WindowMinutes,
- &out.SustainedMinutes,
- &out.CooldownMinutes,
- &out.NotifyEmail,
- &filtersRaw,
- &lastTriggeredAt,
- &out.CreatedAt,
- &out.UpdatedAt,
- ); err != nil {
- return nil, err
- }
- if lastTriggeredAt.Valid {
- v := lastTriggeredAt.Time
- out.LastTriggeredAt = &v
- }
- if len(filtersRaw) > 0 && string(filtersRaw) != "null" {
- var decoded map[string]any
- if err := json.Unmarshal(filtersRaw, &decoded); err == nil {
- out.Filters = decoded
- }
- }
- return &out, nil
- }
- func (r *opsRepository) DeleteAlertRule(ctx context.Context, id int64) error {
- if r == nil || r.db == nil {
- return fmt.Errorf("nil ops repository")
- }
- if id <= 0 {
- return fmt.Errorf("invalid id")
- }
- res, err := r.db.ExecContext(ctx, "DELETE FROM ops_alert_rules WHERE id = $1", id)
- if err != nil {
- return err
- }
- affected, err := res.RowsAffected()
- if err != nil {
- return err
- }
- if affected == 0 {
- return sql.ErrNoRows
- }
- return nil
- }
- func (r *opsRepository) ListAlertEvents(ctx context.Context, filter *service.OpsAlertEventFilter) ([]*service.OpsAlertEvent, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if filter == nil {
- filter = &service.OpsAlertEventFilter{}
- }
- limit := filter.Limit
- if limit <= 0 {
- limit = 100
- }
- if limit > 500 {
- limit = 500
- }
- where, args := buildOpsAlertEventsWhere(filter)
- args = append(args, limit)
- limitArg := "$" + itoa(len(args))
- q := `
- SELECT
- id,
- COALESCE(rule_id, 0),
- COALESCE(severity, ''),
- COALESCE(status, ''),
- COALESCE(title, ''),
- COALESCE(description, ''),
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at
- FROM ops_alert_events
- ` + where + `
- ORDER BY fired_at DESC, id DESC
- LIMIT ` + limitArg
- rows, err := r.db.QueryContext(ctx, q, args...)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- out := []*service.OpsAlertEvent{}
- for rows.Next() {
- var ev service.OpsAlertEvent
- var metricValue sql.NullFloat64
- var thresholdValue sql.NullFloat64
- var dimensionsRaw []byte
- var resolvedAt sql.NullTime
- if err := rows.Scan(
- &ev.ID,
- &ev.RuleID,
- &ev.Severity,
- &ev.Status,
- &ev.Title,
- &ev.Description,
- &metricValue,
- &thresholdValue,
- &dimensionsRaw,
- &ev.FiredAt,
- &resolvedAt,
- &ev.EmailSent,
- &ev.CreatedAt,
- ); err != nil {
- return nil, err
- }
- if metricValue.Valid {
- v := metricValue.Float64
- ev.MetricValue = &v
- }
- if thresholdValue.Valid {
- v := thresholdValue.Float64
- ev.ThresholdValue = &v
- }
- if resolvedAt.Valid {
- v := resolvedAt.Time
- ev.ResolvedAt = &v
- }
- if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
- var decoded map[string]any
- if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
- ev.Dimensions = decoded
- }
- }
- out = append(out, &ev)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return out, nil
- }
- func (r *opsRepository) GetAlertEventByID(ctx context.Context, eventID int64) (*service.OpsAlertEvent, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if eventID <= 0 {
- return nil, fmt.Errorf("invalid event id")
- }
- q := `
- SELECT
- id,
- COALESCE(rule_id, 0),
- COALESCE(severity, ''),
- COALESCE(status, ''),
- COALESCE(title, ''),
- COALESCE(description, ''),
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at
- FROM ops_alert_events
- WHERE id = $1`
- row := r.db.QueryRowContext(ctx, q, eventID)
- ev, err := scanOpsAlertEvent(row)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, nil
- }
- return nil, err
- }
- return ev, nil
- }
- func (r *opsRepository) GetActiveAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if ruleID <= 0 {
- return nil, fmt.Errorf("invalid rule id")
- }
- q := `
- SELECT
- id,
- COALESCE(rule_id, 0),
- COALESCE(severity, ''),
- COALESCE(status, ''),
- COALESCE(title, ''),
- COALESCE(description, ''),
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at
- FROM ops_alert_events
- WHERE rule_id = $1 AND status = $2
- ORDER BY fired_at DESC
- LIMIT 1`
- row := r.db.QueryRowContext(ctx, q, ruleID, service.OpsAlertStatusFiring)
- ev, err := scanOpsAlertEvent(row)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, nil
- }
- return nil, err
- }
- return ev, nil
- }
- func (r *opsRepository) GetLatestAlertEvent(ctx context.Context, ruleID int64) (*service.OpsAlertEvent, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if ruleID <= 0 {
- return nil, fmt.Errorf("invalid rule id")
- }
- q := `
- SELECT
- id,
- COALESCE(rule_id, 0),
- COALESCE(severity, ''),
- COALESCE(status, ''),
- COALESCE(title, ''),
- COALESCE(description, ''),
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at
- FROM ops_alert_events
- WHERE rule_id = $1
- ORDER BY fired_at DESC
- LIMIT 1`
- row := r.db.QueryRowContext(ctx, q, ruleID)
- ev, err := scanOpsAlertEvent(row)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil, nil
- }
- return nil, err
- }
- return ev, nil
- }
- func (r *opsRepository) CreateAlertEvent(ctx context.Context, event *service.OpsAlertEvent) (*service.OpsAlertEvent, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if event == nil {
- return nil, fmt.Errorf("nil event")
- }
- dimensionsArg, err := opsNullJSONMap(event.Dimensions)
- if err != nil {
- return nil, err
- }
- q := `
- INSERT INTO ops_alert_events (
- rule_id,
- severity,
- status,
- title,
- description,
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at
- ) VALUES (
- $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW()
- )
- RETURNING
- id,
- COALESCE(rule_id, 0),
- COALESCE(severity, ''),
- COALESCE(status, ''),
- COALESCE(title, ''),
- COALESCE(description, ''),
- metric_value,
- threshold_value,
- dimensions,
- fired_at,
- resolved_at,
- email_sent,
- created_at`
- row := r.db.QueryRowContext(
- ctx,
- q,
- opsNullInt64(&event.RuleID),
- opsNullString(event.Severity),
- opsNullString(event.Status),
- opsNullString(event.Title),
- opsNullString(event.Description),
- opsNullFloat64(event.MetricValue),
- opsNullFloat64(event.ThresholdValue),
- dimensionsArg,
- event.FiredAt,
- opsNullTime(event.ResolvedAt),
- event.EmailSent,
- )
- return scanOpsAlertEvent(row)
- }
- func (r *opsRepository) UpdateAlertEventStatus(ctx context.Context, eventID int64, status string, resolvedAt *time.Time) error {
- if r == nil || r.db == nil {
- return fmt.Errorf("nil ops repository")
- }
- if eventID <= 0 {
- return fmt.Errorf("invalid event id")
- }
- if strings.TrimSpace(status) == "" {
- return fmt.Errorf("invalid status")
- }
- q := `
- UPDATE ops_alert_events
- SET status = $2,
- resolved_at = $3
- WHERE id = $1`
- _, err := r.db.ExecContext(ctx, q, eventID, strings.TrimSpace(status), opsNullTime(resolvedAt))
- return err
- }
- func (r *opsRepository) UpdateAlertEventEmailSent(ctx context.Context, eventID int64, emailSent bool) error {
- if r == nil || r.db == nil {
- return fmt.Errorf("nil ops repository")
- }
- if eventID <= 0 {
- return fmt.Errorf("invalid event id")
- }
- _, err := r.db.ExecContext(ctx, "UPDATE ops_alert_events SET email_sent = $2 WHERE id = $1", eventID, emailSent)
- return err
- }
- type opsAlertEventRow interface {
- Scan(dest ...any) error
- }
- func (r *opsRepository) CreateAlertSilence(ctx context.Context, input *service.OpsAlertSilence) (*service.OpsAlertSilence, error) {
- if r == nil || r.db == nil {
- return nil, fmt.Errorf("nil ops repository")
- }
- if input == nil {
- return nil, fmt.Errorf("nil input")
- }
- if input.RuleID <= 0 {
- return nil, fmt.Errorf("invalid rule_id")
- }
- platform := strings.TrimSpace(input.Platform)
- if platform == "" {
- return nil, fmt.Errorf("invalid platform")
- }
- if input.Until.IsZero() {
- return nil, fmt.Errorf("invalid until")
- }
- q := `
- INSERT INTO ops_alert_silences (
- rule_id,
- platform,
- group_id,
- region,
- until,
- reason,
- created_by,
- created_at
- ) VALUES (
- $1,$2,$3,$4,$5,$6,$7,NOW()
- )
- RETURNING id, rule_id, platform, group_id, region, until, COALESCE(reason,''), created_by, created_at`
- row := r.db.QueryRowContext(
- ctx,
- q,
- input.RuleID,
- platform,
- opsNullInt64(input.GroupID),
- opsNullString(input.Region),
- input.Until,
- opsNullString(input.Reason),
- opsNullInt64(input.CreatedBy),
- )
- var out service.OpsAlertSilence
- var groupID sql.NullInt64
- var region sql.NullString
- var createdBy sql.NullInt64
- if err := row.Scan(
- &out.ID,
- &out.RuleID,
- &out.Platform,
- &groupID,
- ®ion,
- &out.Until,
- &out.Reason,
- &createdBy,
- &out.CreatedAt,
- ); err != nil {
- return nil, err
- }
- if groupID.Valid {
- v := groupID.Int64
- out.GroupID = &v
- }
- if region.Valid {
- v := strings.TrimSpace(region.String)
- if v != "" {
- out.Region = &v
- }
- }
- if createdBy.Valid {
- v := createdBy.Int64
- out.CreatedBy = &v
- }
- return &out, nil
- }
- func (r *opsRepository) IsAlertSilenced(ctx context.Context, ruleID int64, platform string, groupID *int64, region *string, now time.Time) (bool, error) {
- if r == nil || r.db == nil {
- return false, fmt.Errorf("nil ops repository")
- }
- if ruleID <= 0 {
- return false, fmt.Errorf("invalid rule id")
- }
- platform = strings.TrimSpace(platform)
- if platform == "" {
- return false, nil
- }
- if now.IsZero() {
- now = time.Now().UTC()
- }
- q := `
- SELECT 1
- FROM ops_alert_silences
- WHERE rule_id = $1
- AND platform = $2
- AND (group_id IS NOT DISTINCT FROM $3)
- AND (region IS NOT DISTINCT FROM $4)
- AND until > $5
- LIMIT 1`
- var dummy int
- err := r.db.QueryRowContext(ctx, q, ruleID, platform, opsNullInt64(groupID), opsNullString(region), now).Scan(&dummy)
- if err != nil {
- if err == sql.ErrNoRows {
- return false, nil
- }
- return false, err
- }
- return true, nil
- }
- func scanOpsAlertEvent(row opsAlertEventRow) (*service.OpsAlertEvent, error) {
- var ev service.OpsAlertEvent
- var metricValue sql.NullFloat64
- var thresholdValue sql.NullFloat64
- var dimensionsRaw []byte
- var resolvedAt sql.NullTime
- if err := row.Scan(
- &ev.ID,
- &ev.RuleID,
- &ev.Severity,
- &ev.Status,
- &ev.Title,
- &ev.Description,
- &metricValue,
- &thresholdValue,
- &dimensionsRaw,
- &ev.FiredAt,
- &resolvedAt,
- &ev.EmailSent,
- &ev.CreatedAt,
- ); err != nil {
- return nil, err
- }
- if metricValue.Valid {
- v := metricValue.Float64
- ev.MetricValue = &v
- }
- if thresholdValue.Valid {
- v := thresholdValue.Float64
- ev.ThresholdValue = &v
- }
- if resolvedAt.Valid {
- v := resolvedAt.Time
- ev.ResolvedAt = &v
- }
- if len(dimensionsRaw) > 0 && string(dimensionsRaw) != "null" {
- var decoded map[string]any
- if err := json.Unmarshal(dimensionsRaw, &decoded); err == nil {
- ev.Dimensions = decoded
- }
- }
- return &ev, nil
- }
- func buildOpsAlertEventsWhere(filter *service.OpsAlertEventFilter) (string, []any) {
- clauses := []string{"1=1"}
- args := []any{}
- if filter == nil {
- return "WHERE " + strings.Join(clauses, " AND "), args
- }
- if status := strings.TrimSpace(filter.Status); status != "" {
- args = append(args, status)
- clauses = append(clauses, "status = $"+itoa(len(args)))
- }
- if severity := strings.TrimSpace(filter.Severity); severity != "" {
- args = append(args, severity)
- clauses = append(clauses, "severity = $"+itoa(len(args)))
- }
- if filter.EmailSent != nil {
- args = append(args, *filter.EmailSent)
- clauses = append(clauses, "email_sent = $"+itoa(len(args)))
- }
- if filter.StartTime != nil && !filter.StartTime.IsZero() {
- args = append(args, *filter.StartTime)
- clauses = append(clauses, "fired_at >= $"+itoa(len(args)))
- }
- if filter.EndTime != nil && !filter.EndTime.IsZero() {
- args = append(args, *filter.EndTime)
- clauses = append(clauses, "fired_at < $"+itoa(len(args)))
- }
- // Cursor pagination (descending by fired_at, then id)
- if filter.BeforeFiredAt != nil && !filter.BeforeFiredAt.IsZero() && filter.BeforeID != nil && *filter.BeforeID > 0 {
- args = append(args, *filter.BeforeFiredAt)
- tsArg := "$" + itoa(len(args))
- args = append(args, *filter.BeforeID)
- idArg := "$" + itoa(len(args))
- clauses = append(clauses, fmt.Sprintf("(fired_at < %s OR (fired_at = %s AND id < %s))", tsArg, tsArg, idArg))
- }
- // Dimensions are stored in JSONB. We filter best-effort without requiring GIN indexes.
- if platform := strings.TrimSpace(filter.Platform); platform != "" {
- args = append(args, platform)
- clauses = append(clauses, "(dimensions->>'platform') = $"+itoa(len(args)))
- }
- if filter.GroupID != nil && *filter.GroupID > 0 {
- args = append(args, fmt.Sprintf("%d", *filter.GroupID))
- clauses = append(clauses, "(dimensions->>'group_id') = $"+itoa(len(args)))
- }
- return "WHERE " + strings.Join(clauses, " AND "), args
- }
- func opsNullJSONMap(v map[string]any) (any, error) {
- if v == nil {
- return sql.NullString{}, nil
- }
- b, err := json.Marshal(v)
- if err != nil {
- return nil, err
- }
- if len(b) == 0 {
- return sql.NullString{}, nil
- }
- return sql.NullString{String: string(b), Valid: true}, nil
- }
|