feat(go-services): inventory-go /search/items — query+filters+count (validated)
Ports the search CTE (items_with_slots: combat/req/enh joins + the rating extractions from item_raw_data.int_values JSONB via GREATEST/COALESCE, coverage mask, computed_spell_names), the SQL filters, sort mapping, pagination, and the DISTINCT count query. Returns each row's direct DB columns + computed booleans (is_equipped/bonded/attuned/rare, condition_percent). Validated vs the Python service on the production DB: total_count EXACT across 13 filter combinations (armor/jewelry/min_armor/min_damage/text/material/ min_value/is_rare/rating/equipped/character/workmanship), and 50-row alignment with 0 direct-column mismatches (same SQL sort order, same rows). Deferred to later slices: deep per-row enrichment (extract_item_properties: material_name/spells/slot_name/object_class_name/...), and the enum-dependent filters (has_spell/spell_contains/legendary_cantrips, slot_names, item_set, weapon_type, underwear). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
253250a01d
commit
7e7842128e
2 changed files with 394 additions and 0 deletions
|
|
@ -70,6 +70,7 @@ func main() {
|
||||||
mux.HandleFunc("GET /health", srv.handleHealth)
|
mux.HandleFunc("GET /health", srv.handleHealth)
|
||||||
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
|
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
|
||||||
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
|
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
|
||||||
|
mux.HandleFunc("GET /search/items", srv.handleSearchItems)
|
||||||
|
|
||||||
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() {
|
go func() {
|
||||||
|
|
|
||||||
393
go-services/inventory-go/search.go
Normal file
393
go-services/inventory-go/search.go
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// /search/items — port of inventory-service main.py:2892. This slice implements
|
||||||
|
// the search QUERY (the CTE + all SQL filters + sort + pagination + count) and
|
||||||
|
// returns each row's direct DB columns plus the computed booleans. The deep
|
||||||
|
// per-row translation (material_name, spells, slot_name, ...) from
|
||||||
|
// extract_item_properties is layered on in a later slice; the filter/count logic
|
||||||
|
// — "which items match" — is validated here against the Python service.
|
||||||
|
|
||||||
|
// cteSelect is the items_with_slots CTE body (everything up to FROM/JOINs). The
|
||||||
|
// rating columns are extracted from the item_raw_data int_values JSONB exactly
|
||||||
|
// as Python does (paired ids via GREATEST, singletons via COALESCE).
|
||||||
|
const cteSelect = `
|
||||||
|
SELECT DISTINCT
|
||||||
|
i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden,
|
||||||
|
i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size,
|
||||||
|
i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated,
|
||||||
|
COALESCE(cs.max_damage, -1) AS max_damage,
|
||||||
|
COALESCE(cs.armor_level, -1) AS armor_level,
|
||||||
|
COALESCE(cs.attack_bonus, -1.0) AS attack_bonus,
|
||||||
|
COALESCE(cs.melee_defense_bonus, -1.0) AS melee_defense_bonus,
|
||||||
|
COALESCE(cs.weapon_time, -1) AS weapon_time,
|
||||||
|
COALESCE(cs.base_armor_level, cs.armor_level, -1) AS base_armor_level,
|
||||||
|
COALESCE(cs.base_max_damage, cs.max_damage, -1) AS base_max_damage,
|
||||||
|
GREATEST(COALESCE((rd.int_values->>'314')::int, -1), COALESCE((rd.int_values->>'374')::int, -1)) AS crit_damage_rating,
|
||||||
|
GREATEST(COALESCE((rd.int_values->>'307')::int, -1), COALESCE((rd.int_values->>'370')::int, -1)) AS damage_rating,
|
||||||
|
GREATEST(COALESCE((rd.int_values->>'323')::int, -1), COALESCE((rd.int_values->>'376')::int, -1)) AS heal_boost_rating,
|
||||||
|
COALESCE((rd.int_values->>'379')::int, -1) AS vitality_rating,
|
||||||
|
GREATEST(COALESCE((rd.int_values->>'308')::int, -1), COALESCE((rd.int_values->>'371')::int, -1)) AS damage_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'315')::int, -1) AS crit_resist_rating,
|
||||||
|
GREATEST(COALESCE((rd.int_values->>'316')::int, -1), COALESCE((rd.int_values->>'375')::int, -1)) AS crit_damage_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'317')::int, -1) AS healing_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'331')::int, -1) AS nether_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'342')::int, -1) AS healing_rating,
|
||||||
|
COALESCE((rd.int_values->>'350')::int, -1) AS dot_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'351')::int, -1) AS life_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'356')::int, -1) AS sneak_attack_rating,
|
||||||
|
COALESCE((rd.int_values->>'357')::int, -1) AS recklessness_rating,
|
||||||
|
COALESCE((rd.int_values->>'358')::int, -1) AS deception_rating,
|
||||||
|
COALESCE((rd.int_values->>'381')::int, -1) AS pk_damage_rating,
|
||||||
|
COALESCE((rd.int_values->>'382')::int, -1) AS pk_damage_resist_rating,
|
||||||
|
COALESCE((rd.int_values->>'383')::int, -1) AS gear_pk_damage_rating,
|
||||||
|
COALESCE((rd.int_values->>'384')::int, -1) AS gear_pk_damage_resist_rating,
|
||||||
|
COALESCE(req.wield_level, -1) AS wield_level,
|
||||||
|
COALESCE(enh.material, '') AS material,
|
||||||
|
COALESCE(enh.workmanship, -1.0) AS workmanship,
|
||||||
|
COALESCE(enh.imbue, '') AS imbue,
|
||||||
|
COALESCE(enh.tinks, -1) AS tinks,
|
||||||
|
COALESCE(enh.item_set, '') AS item_set,
|
||||||
|
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
|
||||||
|
COALESCE((SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
|
||||||
|
FROM item_spells sp_inner WHERE sp_inner.item_id = i.id), '') AS computed_spell_names
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
||||||
|
LEFT JOIN item_requirements req ON i.id = req.item_id
|
||||||
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
||||||
|
LEFT JOIN item_ratings rt ON i.id = rt.item_id
|
||||||
|
LEFT JOIN item_raw_data rd ON i.id = rd.item_id`
|
||||||
|
|
||||||
|
var sortMapping = map[string]string{
|
||||||
|
"name": "name", "character_name": "character_name", "value": "value",
|
||||||
|
"damage": "max_damage", "armor": "armor_level", "armor_level": "armor_level",
|
||||||
|
"workmanship": "workmanship", "level": "wield_level", "damage_rating": "damage_rating",
|
||||||
|
"crit_damage_rating": "crit_damage_rating", "heal_boost_rating": "heal_boost_rating",
|
||||||
|
"vitality_rating": "vitality_rating", "damage_resist_rating": "damage_resist_rating",
|
||||||
|
"crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set",
|
||||||
|
"coverage": "coverage_mask", "item_type_name": "object_class",
|
||||||
|
"last_updated": "last_updated", "spell_names": "computed_spell_names",
|
||||||
|
}
|
||||||
|
|
||||||
|
// argBuilder accumulates positional ($N) query args.
|
||||||
|
type argBuilder struct{ args []any }
|
||||||
|
|
||||||
|
func (b *argBuilder) add(v any) string {
|
||||||
|
b.args = append(b.args, v)
|
||||||
|
return "$" + strconv.Itoa(len(b.args))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
ab := &argBuilder{}
|
||||||
|
var conds []string
|
||||||
|
|
||||||
|
// --- character (mutually exclusive cascade) ---
|
||||||
|
if c := q.Get("character"); c != "" {
|
||||||
|
conds = append(conds, "character_name = "+ab.add(c))
|
||||||
|
} else if cs := q.Get("characters"); cs != "" {
|
||||||
|
names := splitNonEmpty(cs)
|
||||||
|
if len(names) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ph := make([]string, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
ph[i] = ab.add(n)
|
||||||
|
}
|
||||||
|
conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
|
||||||
|
} else if !qBool(q, "include_all_characters") {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- text ---
|
||||||
|
if t := q.Get("text"); t != "" {
|
||||||
|
p := ab.add("%" + t + "%")
|
||||||
|
conds = append(conds, "(CONCAT(COALESCE(material,''),' ',name) ILIKE "+p+" OR name ILIKE "+p+" OR COALESCE(material,'') ILIKE "+p+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- category (mutually exclusive) ---
|
||||||
|
switch {
|
||||||
|
case qBool(q, "armor_only"):
|
||||||
|
conds = append(conds, "(object_class = 2 AND COALESCE(armor_level,0) > 0)")
|
||||||
|
case qBool(q, "jewelry_only"):
|
||||||
|
conds = append(conds, "object_class = 4")
|
||||||
|
case qBool(q, "weapon_only"):
|
||||||
|
conds = append(conds, "object_class IN (1, 9, 31)") // weapon_type refinement: later slice
|
||||||
|
case qBool(q, "clothing_only"):
|
||||||
|
conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- equipment status / slot ---
|
||||||
|
switch q.Get("equipment_status") {
|
||||||
|
case "equipped":
|
||||||
|
conds = append(conds, "current_wielded_location > 0")
|
||||||
|
case "unequipped":
|
||||||
|
conds = append(conds, "current_wielded_location = 0")
|
||||||
|
}
|
||||||
|
if v, ok := qInt(q, "equipment_slot"); ok {
|
||||||
|
conds = append(conds, "current_wielded_location = "+ab.add(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- combat + all rating filters (column >= :param) ---
|
||||||
|
geFilters := []struct{ param, col string }{
|
||||||
|
{"min_damage", "max_damage"}, {"min_armor", "armor_level"},
|
||||||
|
{"min_crit_damage_rating", "crit_damage_rating"}, {"min_damage_rating", "damage_rating"},
|
||||||
|
{"min_heal_boost_rating", "heal_boost_rating"}, {"min_vitality_rating", "vitality_rating"},
|
||||||
|
{"min_damage_resist_rating", "damage_resist_rating"}, {"min_crit_resist_rating", "crit_resist_rating"},
|
||||||
|
{"min_crit_damage_resist_rating", "crit_damage_resist_rating"}, {"min_healing_resist_rating", "healing_resist_rating"},
|
||||||
|
{"min_nether_resist_rating", "nether_resist_rating"}, {"min_healing_rating", "healing_rating"},
|
||||||
|
{"min_dot_resist_rating", "dot_resist_rating"}, {"min_life_resist_rating", "life_resist_rating"},
|
||||||
|
{"min_sneak_attack_rating", "sneak_attack_rating"}, {"min_recklessness_rating", "recklessness_rating"},
|
||||||
|
{"min_deception_rating", "deception_rating"}, {"min_pk_damage_rating", "pk_damage_rating"},
|
||||||
|
{"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"},
|
||||||
|
{"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"},
|
||||||
|
{"min_value", "value"}, {"min_workmanship", "workmanship"},
|
||||||
|
}
|
||||||
|
for _, f := range geFilters {
|
||||||
|
if v := q.Get(f.param); v != "" {
|
||||||
|
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
conds = append(conds, f.col+" >= "+ab.add(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leFilters := []struct{ param, col string }{
|
||||||
|
{"max_damage", "max_damage"}, {"max_armor", "armor_level"},
|
||||||
|
{"max_value", "value"}, {"max_burden", "burden"},
|
||||||
|
}
|
||||||
|
for _, f := range leFilters {
|
||||||
|
if v, ok := qInt(q, f.param); ok {
|
||||||
|
conds = append(conds, f.col+" <= "+ab.add(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := q.Get("min_attack_bonus"); v != "" {
|
||||||
|
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
conds = append(conds, "attack_bonus >= "+ab.add(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- requirements (wield level) ---
|
||||||
|
if v, ok := qInt(q, "max_level"); ok {
|
||||||
|
conds = append(conds, "(wield_level <= "+ab.add(v)+" OR wield_level IS NULL)")
|
||||||
|
}
|
||||||
|
if v, ok := qInt(q, "min_level"); ok {
|
||||||
|
conds = append(conds, "wield_level >= "+ab.add(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- enhancements ---
|
||||||
|
if m := q.Get("material"); m != "" {
|
||||||
|
conds = append(conds, "material ILIKE "+ab.add("%"+m+"%"))
|
||||||
|
}
|
||||||
|
if v := q.Get("has_imbue"); v != "" {
|
||||||
|
if qBool(q, "has_imbue") {
|
||||||
|
conds = append(conds, "(imbue IS NOT NULL AND imbue != '')")
|
||||||
|
} else {
|
||||||
|
conds = append(conds, "(imbue IS NULL OR imbue = '')")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- item state ---
|
||||||
|
if v := q.Get("bonded"); v != "" {
|
||||||
|
conds = append(conds, ternary(qBool(q, "bonded"), "bonded > 0", "bonded = 0"))
|
||||||
|
}
|
||||||
|
if v := q.Get("attuned"); v != "" {
|
||||||
|
conds = append(conds, ternary(qBool(q, "attuned"), "attuned > 0", "attuned = 0"))
|
||||||
|
}
|
||||||
|
if v := q.Get("unique"); v != "" {
|
||||||
|
conds = append(conds, `"unique" = `+ab.add(qBool(q, "unique")))
|
||||||
|
}
|
||||||
|
if v := q.Get("is_rare"); v != "" {
|
||||||
|
conds = append(conds, ternary(qBool(q, "is_rare"), "rare_id IS NOT NULL AND rare_id > 0", "(rare_id IS NULL OR rare_id <= 0)"))
|
||||||
|
}
|
||||||
|
if v, ok := qInt(q, "min_condition"); ok {
|
||||||
|
conds = append(conds, "((structure * 100.0 / NULLIF(max_structure, 0)) >= "+ab.add(v)+" OR max_structure IS NULL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
where := ""
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where = " WHERE " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sort ---
|
||||||
|
sortField, ok := sortMapping[q.Get("sort_by")]
|
||||||
|
if !ok {
|
||||||
|
sortField = "name"
|
||||||
|
}
|
||||||
|
dir, nulls := "ASC", "NULLS LAST"
|
||||||
|
if strings.EqualFold(q.Get("sort_dir"), "desc") {
|
||||||
|
dir, nulls = "DESC", "NULLS FIRST"
|
||||||
|
}
|
||||||
|
orderBy := fmt.Sprintf(" ORDER BY %s %s %s, character_name, db_item_id", sortField, dir, nulls)
|
||||||
|
|
||||||
|
// --- pagination ---
|
||||||
|
page := clampInt(qIntDefault(q, "page", 1), 1, 1<<30)
|
||||||
|
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cte := "WITH items_with_slots AS (" + cteSelect + ")\n"
|
||||||
|
mainSQL := cte + "SELECT * FROM items_with_slots" + where + orderBy +
|
||||||
|
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
||||||
|
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
||||||
|
if err != nil {
|
||||||
|
s.dbErr(w, "search/items", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// count uses the same CTE + conditions (full CTE is correct; Python trims it
|
||||||
|
// only as an optimization). LIMIT/OFFSET args are unused here.
|
||||||
|
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
|
||||||
|
var totalCount int64
|
||||||
|
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
|
||||||
|
s.dbErr(w, "search/items count", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range row2items(rows) {
|
||||||
|
items = append(items, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"total_count": totalCount,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"has_next": int64(page*limit) < totalCount,
|
||||||
|
"has_previous": page > 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// row2items applies the direct-column transforms (computed booleans, condition,
|
||||||
|
// timestamp formatting) and strips internal columns. Deep enrichment is a later
|
||||||
|
// slice.
|
||||||
|
func row2items(rows []map[string]any) []map[string]any {
|
||||||
|
for _, row := range rows {
|
||||||
|
cw := toInt64(row["current_wielded_location"])
|
||||||
|
row["is_equipped"] = cw > 0
|
||||||
|
row["is_bonded"] = toInt64(row["bonded"]) > 0
|
||||||
|
row["is_attuned"] = toInt64(row["attuned"]) > 0
|
||||||
|
row["is_rare"] = toInt64(row["rare_id"]) > 0
|
||||||
|
st, mx := row["structure"], row["max_structure"]
|
||||||
|
if st != nil && mx != nil && toFloat(mx) != 0 {
|
||||||
|
row["condition_percent"] = roundTo(toFloat(st)*100/toFloat(mx), 1)
|
||||||
|
} else {
|
||||||
|
row["condition_percent"] = nil
|
||||||
|
}
|
||||||
|
if t, ok := row["last_updated"].(time.Time); ok {
|
||||||
|
row["last_updated"] = pyISO(t)
|
||||||
|
}
|
||||||
|
delete(row, "db_item_id")
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitNonEmpty(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func qBool(q map[string][]string, key string) bool {
|
||||||
|
v := ""
|
||||||
|
if vs, ok := q[key]; ok && len(vs) > 0 {
|
||||||
|
v = vs[0]
|
||||||
|
}
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func qInt(q map[string][]string, key string) (int, bool) {
|
||||||
|
if vs, ok := q[key]; ok && len(vs) > 0 && vs[0] != "" {
|
||||||
|
if n, err := strconv.Atoi(vs[0]); err == nil {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func qIntDefault(q map[string][]string, key string, def int) int {
|
||||||
|
if n, ok := qInt(q, key); ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func ternary(c bool, a, b string) string {
|
||||||
|
if c {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(v, lo, hi int) int {
|
||||||
|
if v < lo {
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int64:
|
||||||
|
return x
|
||||||
|
case int32:
|
||||||
|
return int64(x)
|
||||||
|
case int:
|
||||||
|
return int64(x)
|
||||||
|
case float64:
|
||||||
|
return int64(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(v any) float64 {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return x
|
||||||
|
case float32:
|
||||||
|
return float64(x)
|
||||||
|
case int64:
|
||||||
|
return float64(x)
|
||||||
|
case int32:
|
||||||
|
return float64(x)
|
||||||
|
case int:
|
||||||
|
return float64(x)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTo(v float64, places int) float64 {
|
||||||
|
p := 1.0
|
||||||
|
for i := 0; i < places; i++ {
|
||||||
|
p *= 10
|
||||||
|
}
|
||||||
|
r := v * p
|
||||||
|
if r < 0 {
|
||||||
|
r -= 0.5
|
||||||
|
} else {
|
||||||
|
r += 0.5
|
||||||
|
}
|
||||||
|
return float64(int64(r)) / p
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue