package index import ( "fmt" "strings" "time" ) // SearchQuery describes an index search. Zero-valued fields are omitted. type SearchQuery struct { Subject string // NATS-style glob: '*' = one token, '>' = rest. Empty = any. Host string // exact, or a glob containing '*'. Empty = any. From time.Time // objects whose range overlaps [From,To] To time.Time Limit int } // buildSearchSQL renders q into a parameterized ClickHouse SELECT and its args. // It is pure so it can be unit-tested without a database. Placeholders use the // clickhouse-go positional style (?), matching Query(ctx, sql, args...). func buildSearchSQL(database, table string, q SearchQuery) (string, []any) { var ( where []string args []any ) if q.Subject != "" { where = append(where, "match(subject, ?)") args = append(args, subjectToRegex(q.Subject)) } if q.Host != "" { if strings.Contains(q.Host, "*") { where = append(where, "arrayExists(h -> match(h, ?), hosts)") args = append(args, hostGlobToRegex(q.Host)) } else { where = append(where, "has(hosts, ?)") args = append(args, q.Host) } } if !q.From.IsZero() { // object overlaps the window if its max_ts is at/after From. where = append(where, "max_ts >= ?") args = append(args, q.From.UTC()) } if !q.To.IsZero() { where = append(where, "min_ts <= ?") args = append(args, q.To.UTC()) } sql := fmt.Sprintf( "SELECT object_key, bucket, subject, hosts, min_ts, max_ts, event_count, raw_bytes, stored_bytes, key_name, key_fingerprint FROM %s.%s", database, table) if len(where) > 0 { sql += " WHERE " + strings.Join(where, " AND ") } sql += " ORDER BY min_ts, object_key" if q.Limit > 0 { sql += " LIMIT ?" args = append(args, q.Limit) } return sql, args } // subjectToRegex converts a NATS-style subject glob into an anchored regex for // ClickHouse match(). '*' matches exactly one dot-delimited token; '>' (only // meaningful as the final token) matches one or more trailing tokens. Literal // dots and regex metacharacters are escaped. func subjectToRegex(glob string) string { tokens := strings.Split(glob, ".") var parts []string for i, tok := range tokens { switch tok { case "*": parts = append(parts, `[^.]+`) case ">": // '>' consumes the rest; emit and stop. if i == 0 { return "^.+$" } return "^" + strings.Join(parts[:i], `\.`) + `(\..+)?$` default: parts = append(parts, regexEscape(tok)) } } return "^" + strings.Join(parts, `\.`) + "$" } // hostGlobToRegex converts a host glob (where '*' matches any run of // characters, including dots in an FQDN) into an anchored regex for match(). func hostGlobToRegex(glob string) string { var b strings.Builder b.WriteByte('^') for _, seg := range strings.Split(glob, "*") { b.WriteString(regexEscape(seg)) b.WriteString(".*") } // Trim the trailing ".*" added after the last segment, then anchor. out := strings.TrimSuffix(b.String(), ".*") return out + "$" } func regexEscape(s string) string { const meta = `\.+*?()|[]{}^$` var b strings.Builder for _, r := range s { if strings.ContainsRune(meta, r) { b.WriteByte('\\') } b.WriteRune(r) } return b.String() }