| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671 |
- package service
- import (
- "sort"
- "strconv"
- "strings"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/database"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/xray"
- "gorm.io/gorm"
- )
- // ClientSlim is the row-shape used by the clients page. It drops fields the
- // table never reads (UUID, password, auth, flow, security, reverse, tgId)
- // so the list payload stays compact even when the panel manages thousands
- // of clients. Modals that need the full record still call /get/:email.
- type ClientSlim struct {
- Email string `json:"email"`
- SubID string `json:"subId"`
- Enable bool `json:"enable"`
- TotalGB int64 `json:"totalGB"`
- ExpiryTime int64 `json:"expiryTime"`
- LimitIP int `json:"limitIp"`
- Reset int `json:"reset"`
- Group string `json:"group,omitempty"`
- Comment string `json:"comment,omitempty"`
- InboundIds []int `json:"inboundIds"`
- Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
- CreatedAt int64 `json:"createdAt"`
- UpdatedAt int64 `json:"updatedAt"`
- }
- // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
- // All fields are optional — the empty value means "no filter" / defaults.
- //
- // Filter / Protocol / Inbound accept either a single value or a comma-separated
- // list; matching is OR within a field and AND across fields. The numeric range
- // fields treat 0 as "unset" on the lower bound and 0 (or negative) as
- // "unbounded" on the upper bound.
- type ClientPageParams struct {
- Page int `form:"page"`
- PageSize int `form:"pageSize"`
- Search string `form:"search"`
- Filter string `form:"filter"`
- Protocol string `form:"protocol"`
- Inbound string `form:"inbound"`
- Sort string `form:"sort"`
- Order string `form:"order"`
- ExpiryFrom int64 `form:"expiryFrom"`
- ExpiryTo int64 `form:"expiryTo"`
- UsageFrom int64 `form:"usageFrom"`
- UsageTo int64 `form:"usageTo"`
- AutoRenew string `form:"autoRenew"`
- HasTgID string `form:"hasTgId"`
- HasComment string `form:"hasComment"`
- Group string `form:"group"`
- }
- // ClientPageResponse is the shape returned by ListPaged. `Total` is the
- // row count in the DB; `Filtered` is the count after Search/Filter/Protocol
- // were applied, before pagination. The page contains at most PageSize items.
- // Summary is computed across the full DB row set so dashboard counters
- // on the clients page stay stable as the user paginates/filters.
- type ClientPageResponse struct {
- Items []ClientSlim `json:"items"`
- Total int `json:"total"`
- Filtered int `json:"filtered"`
- Page int `json:"page"`
- PageSize int `json:"pageSize"`
- Summary ClientsSummary `json:"summary"`
- Groups []string `json:"groups"`
- }
- // ClientsSummary collects per-bucket counts plus the matching email lists so
- // the clients page can render the dashboard stat cards and their hover
- // popovers without shipping the full client array. The counters are exact;
- // the lists stop at clientSummaryEmailCap entries and only back the popovers.
- type ClientsSummary struct {
- Total int `json:"total"`
- Active int `json:"active"`
- OnlineCount int `json:"onlineCount"`
- DepletedCount int `json:"depletedCount"`
- ExpiringCount int `json:"expiringCount"`
- DeactiveCount int `json:"deactiveCount"`
- Online []string `json:"online"`
- Depleted []string `json:"depleted"`
- Expiring []string `json:"expiring"`
- Deactive []string `json:"deactive"`
- }
- const (
- clientPageDefaultSize = 25
- clientPageMaxSize = 200
- // clientSummaryEmailCap bounds each bucket's email list. Shipping every
- // matching email made the response — and the Zod validation the page runs
- // over it — grow with the client count on a request that repeats every 5s,
- // and left the hover popover rendering thousands of rows.
- clientSummaryEmailCap = 200
- // sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
- // matching the sentinel the in-memory comparator used.
- sqlNeverSentinel = "4611686018427387903"
- // sqlClientEnabled tolerates a NULL enable column, which GORM scans as
- // false: without the COALESCE such a row would match neither the enabled
- // nor the disabled branch of any predicate.
- sqlClientEnabled = "COALESCE(c.enable, FALSE)"
- )
- const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
- OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
- OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
- OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
- OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
- OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
- OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
- // clientQuery builds the statements behind the clients page: a clients row
- // joined to its traffic counters, plus the expressions every bucket predicate
- // shares. Filtering, sorting, paging and the summary all run in the database.
- // Loading every client (with attachments and traffic) into Go and doing it in
- // memory cost ~200ms per request at 20k clients on a page that polls every
- // 5 seconds, which is what made the table feel stuck on large panels.
- type clientQuery struct {
- db *gorm.DB
- joins []clientQueryJoin
- usedExpr string
- nowMs int64
- expireDiffMs int64
- trafficDiffBytes int64
- }
- type clientQueryJoin struct {
- sql string
- args []any
- }
- func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
- q := clientQuery{
- db: db,
- nowMs: nowMs,
- expireDiffMs: expireDiffMs,
- trafficDiffBytes: trafficDiffBytes,
- joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
- usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
- }
- freshSince := globalTrafficFreshSince()
- var probe int64
- err := db.Model(&model.ClientGlobalTraffic{}).
- Where("updated_at >= ?", freshSince).
- Limit(1).Count(&probe).Error
- if err != nil || probe == 0 {
- return q
- }
- // A master still pushes cross-panel usage here, so the predicates have to
- // see the same raised counters overlayGlobalTraffic applies on read.
- q.joins = append(q.joins, clientQueryJoin{
- sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
- " WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
- args: []any{freshSince},
- })
- q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
- " + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
- return q
- }
- func (q clientQuery) from() *gorm.DB {
- tx := q.db.Table("clients AS c")
- for _, j := range q.joins {
- tx = tx.Joins(j.sql, j.args...)
- }
- return tx
- }
- func (q clientQuery) depletedExpr() string {
- return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
- " OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
- }
- func (q clientQuery) nearDepletionExpr() string {
- return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
- " OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
- }
- func (q clientQuery) expiringExpr() string {
- return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
- }
- func (q clientQuery) activeExpr() string {
- return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
- }
- // summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
- // client that also ran out counts once, under depleted, so the stat cards add
- // up to the client total.
- func (q clientQuery) summaryDeactiveExpr() string {
- return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
- }
- // applyParams narrows tx by every predicate the clients page sends. Matching is
- // OR within a field and AND across fields, mirroring the query-param contract.
- // The second return says whether anything narrowed the set, so an unfiltered
- // request can reuse the total count instead of scanning for it again.
- func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
- narrowed := false
- where := func(cond string, args ...any) {
- narrowed = true
- tx = tx.Where(cond, args...)
- }
- if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
- pattern := "%" + escapeLikeLiteral(needle) + "%"
- where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
- }
- if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
- where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
- " WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
- }
- if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
- where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
- }
- if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
- cond, args := q.bucketCond(buckets, onlines)
- where(cond, args...)
- }
- if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
- // 0 means "never expires" and a negative value is the delayed-start
- // sentinel; both sit outside any bounded range.
- where("c.expiry_time > 0")
- if params.ExpiryFrom > 0 {
- where("c.expiry_time >= ?", params.ExpiryFrom)
- }
- if params.ExpiryTo > 0 {
- where("c.expiry_time <= ?", params.ExpiryTo)
- }
- }
- if params.UsageFrom > 0 {
- where(q.usedExpr+" >= ?", params.UsageFrom)
- }
- if params.UsageTo > 0 {
- where(q.usedExpr+" <= ?", params.UsageTo)
- }
- switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
- case "on":
- where("COALESCE(c.reset, 0) > 0")
- case "off":
- where("COALESCE(c.reset, 0) <= 0")
- }
- switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
- case "yes":
- where("COALESCE(c.tg_id, 0) <> 0")
- case "no":
- where("COALESCE(c.tg_id, 0) = 0")
- }
- switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
- case "yes":
- where("TRIM(COALESCE(c.comment, '')) <> ''")
- case "no":
- where("TRIM(COALESCE(c.comment, '')) = ''")
- }
- if groups := parseCSVStrings(params.Group); len(groups) > 0 {
- where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
- }
- return tx, narrowed
- }
- func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
- conds := make([]string, 0, len(buckets))
- args := make([]any, 0, len(buckets))
- for _, b := range buckets {
- switch b {
- case "active":
- conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
- case "deactive":
- conds = append(conds, "(NOT "+sqlClientEnabled+")")
- case "depleted":
- conds = append(conds, q.depletedExpr())
- case "expiring":
- conds = append(conds, q.expiringExpr())
- case "online":
- cond, inArgs := emailInCond("c.email", onlines)
- conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
- args = append(args, inArgs...)
- default:
- // An unrecognised bucket name matched every client before the
- // predicates moved into SQL; keep that so a stale saved filter
- // cannot silently empty the table.
- conds = append(conds, "(1 = 1)")
- }
- }
- return "(" + strings.Join(conds, " OR ") + ")", args
- }
- func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
- dir := " ASC"
- if order == "descend" {
- dir = " DESC"
- }
- // createdAt / updatedAt / lastOnline broke ties on the client id inside the
- // comparator, so reversing the sort reversed the tiebreak with it. The
- // other keys leaned on a stable sort over an id-ordered slice instead.
- tieDir := " ASC"
- var expr string
- switch sortKey {
- case "enable":
- expr = sqlClientEnabled
- case "email":
- expr = "LOWER(c.email)"
- case "inboundIds":
- expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
- case "traffic":
- expr = q.usedExpr
- case "remaining":
- expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
- case "expiryTime":
- expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
- case "createdAt":
- expr, tieDir = "c.created_at", dir
- case "updatedAt":
- expr, tieDir = "c.updated_at", dir
- case "lastOnline":
- expr, tieDir = "COALESCE(ct.last_online, 0)", dir
- default:
- return tx.Order("c.id ASC")
- }
- return tx.Order(expr + dir + ", c.id" + tieDir)
- }
- // ListPaged returns one page of clients together with the counts the clients
- // page header needs. Every predicate runs in SQL, so the cost tracks the page
- // size rather than the number of clients on the panel.
- func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
- db := database.GetDB()
- pageSize := params.PageSize
- if pageSize <= 0 {
- pageSize = clientPageDefaultSize
- }
- if pageSize > clientPageMaxSize {
- pageSize = clientPageMaxSize
- }
- page := params.Page
- if page <= 0 {
- page = 1
- }
- var expireDiffMs, trafficDiffBytes int64
- if settingSvc != nil {
- if v, err := settingSvc.GetExpireDiff(); err == nil {
- expireDiffMs = int64(v) * 86400000
- }
- if v, err := settingSvc.GetTrafficDiff(); err == nil {
- trafficDiffBytes = int64(v) * 1073741824
- }
- }
- onlines := inboundSvc.GetOnlineClients()
- q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
- var total int64
- if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
- return nil, err
- }
- summary, err := q.summary(onlines, int(total))
- if err != nil {
- return nil, err
- }
- filtered := total
- if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
- if err := scoped.Count(&filtered).Error; err != nil {
- return nil, err
- }
- }
- items := []ClientSlim{}
- offset := (page - 1) * pageSize
- if int64(offset) < filtered {
- items, err = q.pageRows(params, onlines, offset, pageSize)
- if err != nil {
- return nil, err
- }
- }
- groups, err := s.listGroupNames()
- if err != nil {
- return nil, err
- }
- return &ClientPageResponse{
- Items: items,
- Total: int(total),
- Filtered: int(filtered),
- Page: page,
- PageSize: pageSize,
- Summary: summary,
- Groups: groups,
- }, nil
- }
- // pageRows resolves the requested page to client ids, then loads the records,
- // attachments and traffic for those ids only. A page never exceeds
- // clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
- // lists need no chunking.
- func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
- tx, _ := q.applyParams(q.from(), params, onlines)
- var ids []int
- if err := q.applyOrder(tx, params.Sort, params.Order).
- Offset(offset).Limit(limit).
- Pluck("c.id", &ids).Error; err != nil {
- return nil, err
- }
- if len(ids) == 0 {
- return []ClientSlim{}, nil
- }
- var records []model.ClientRecord
- if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
- return nil, err
- }
- byId := make(map[int]*model.ClientRecord, len(records))
- emails := make([]string, 0, len(records))
- for i := range records {
- byId[records[i].Id] = &records[i]
- if records[i].Email != "" {
- emails = append(emails, records[i].Email)
- }
- }
- var links []model.ClientInbound
- if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
- return nil, err
- }
- attachments := make(map[int][]int, len(ids))
- for _, l := range links {
- attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
- }
- trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
- if len(emails) > 0 {
- var stats []xray.ClientTraffic
- if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
- return nil, err
- }
- overlayGlobalTrafficValues(q.db, stats)
- for i := range stats {
- trafficByEmail[stats[i].Email] = &stats[i]
- }
- }
- items := make([]ClientSlim, 0, len(ids))
- for _, id := range ids {
- rec := byId[id]
- if rec == nil {
- continue
- }
- items = append(items, ClientSlim{
- Email: rec.Email,
- SubID: rec.SubID,
- Enable: rec.Enable,
- TotalGB: rec.TotalGB,
- ExpiryTime: rec.ExpiryTime,
- LimitIP: rec.LimitIP,
- Reset: rec.Reset,
- Group: rec.Group,
- Comment: rec.Comment,
- InboundIds: attachments[rec.Id],
- Traffic: trafficByEmail[rec.Email],
- CreatedAt: rec.CreatedAt,
- UpdatedAt: rec.UpdatedAt,
- })
- }
- return items, nil
- }
- func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
- s := ClientsSummary{
- Total: total,
- Online: []string{},
- Depleted: []string{},
- Expiring: []string{},
- Deactive: []string{},
- }
- var counts struct {
- Active int64
- Depleted int64
- Expiring int64
- Deactive int64
- }
- // SUM over an empty table yields NULL, which not every driver scans into an
- // int; COALESCE keeps a panel with no clients from erroring out.
- if err := q.from().Select(
- "COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
- " COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
- " COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
- " COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
- ).Scan(&counts).Error; err != nil {
- return s, err
- }
- s.Active = int(counts.Active)
- s.DepletedCount = int(counts.Depleted)
- s.ExpiringCount = int(counts.Expiring)
- s.DeactiveCount = int(counts.Deactive)
- buckets := []struct {
- cond string
- count int
- out *[]string
- }{
- {q.depletedExpr(), s.DepletedCount, &s.Depleted},
- {q.expiringExpr(), s.ExpiringCount, &s.Expiring},
- {q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
- }
- for _, b := range buckets {
- // The counter already says the bucket is empty, so skip the scan that
- // would look for emails it cannot find.
- if b.count == 0 {
- continue
- }
- var emails []string
- if err := q.from().Where(b.cond).
- Order("c.id ASC").Limit(clientSummaryEmailCap).
- Pluck("c.email", &emails).Error; err != nil {
- return s, err
- }
- if len(emails) > 0 {
- *b.out = emails
- }
- }
- online, onlineCount, err := q.onlineEmails(onlines)
- if err != nil {
- return s, err
- }
- s.Online = online
- s.OnlineCount = onlineCount
- return s, nil
- }
- // onlineEmails intersects the emails xray reports as connected with the enabled
- // clients this panel stores. The online set lives in memory and is bounded by
- // live connections, so it drives the query rather than a scan of every client.
- func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
- matched := []string{}
- count := 0
- for _, batch := range chunkStrings(onlines, sqlInChunk) {
- var page []string
- if err := q.db.Model(&model.ClientRecord{}).
- Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
- Order("id ASC").
- Pluck("email", &page).Error; err != nil {
- return nil, 0, err
- }
- count += len(page)
- if room := clientSummaryEmailCap - len(matched); room > 0 {
- matched = append(matched, page[:min(room, len(page))]...)
- }
- }
- return matched, count, nil
- }
- // listGroupNames returns the group names the clients page offers as filters:
- // the stored groups plus any name a client still carries. ListGroups also sums
- // per-client traffic per group, which this page never reads and which costs a
- // full join over client_traffics on every poll.
- func (s *ClientService) listGroupNames() ([]string, error) {
- db := database.GetDB()
- var stored []string
- if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
- return nil, err
- }
- var used []string
- if err := db.Model(&model.ClientRecord{}).
- Where("group_name <> ''").
- Distinct().
- Pluck("group_name", &used).Error; err != nil {
- return nil, err
- }
- seen := make(map[string]struct{}, len(stored)+len(used))
- out := make([]string, 0, len(stored)+len(used))
- for _, list := range [][]string{stored, used} {
- for _, name := range list {
- if name == "" {
- continue
- }
- if _, dup := seen[name]; dup {
- continue
- }
- seen[name] = struct{}{}
- out = append(out, name)
- }
- }
- sort.Slice(out, func(i, j int) bool {
- return strings.ToLower(out[i]) < strings.ToLower(out[j])
- })
- return out, nil
- }
- func sqlInt(v int64) string {
- return strconv.FormatInt(v, 10)
- }
- // escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
- // matching literally, the way strings.Contains did.
- func escapeLikeLiteral(s string) string {
- return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
- }
- // emailInCond renders an IN over a possibly large email set, split so no single
- // IN list outgrows the drivers' bind-parameter ceiling.
- func emailInCond(column string, emails []string) (string, []any) {
- if len(emails) == 0 {
- return "1 = 0", nil
- }
- chunks := chunkStrings(emails, sqlInChunk)
- parts := make([]string, 0, len(chunks))
- args := make([]any, 0, len(chunks))
- for _, chunk := range chunks {
- parts = append(parts, column+" IN ?")
- args = append(args, chunk)
- }
- return "(" + strings.Join(parts, " OR ") + ")", args
- }
- // parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
- // and drops blanks. Returns nil when the input has no usable entries — the
- // caller can then skip the predicate entirely.
- func parseCSVStrings(raw string) []string {
- if raw == "" {
- return nil
- }
- parts := strings.Split(raw, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- s := strings.ToLower(strings.TrimSpace(p))
- if s != "" {
- out = append(out, s)
- }
- }
- if len(out) == 0 {
- return nil
- }
- return out
- }
- // parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
- // non-positive entries are silently dropped.
- func parseCSVInts(raw string) []int {
- if raw == "" {
- return nil
- }
- parts := strings.Split(raw, ",")
- out := make([]int, 0, len(parts))
- for _, p := range parts {
- s := strings.TrimSpace(p)
- if s == "" {
- continue
- }
- if n, err := strconv.Atoi(s); err == nil && n > 0 {
- out = append(out, n)
- }
- }
- if len(out) == 0 {
- return nil
- }
- return out
- }
|