656 lines
16 KiB
Go
656 lines
16 KiB
Go
package tree
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
RootID = "root"
|
|
UnsortedID = "unsorted"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("node not found")
|
|
ErrConflict = errors.New("operation conflicts with tree rules")
|
|
ErrInvalid = errors.New("invalid node data")
|
|
)
|
|
|
|
type Node struct {
|
|
ID string `json:"id"`
|
|
LookupCode string `json:"lookupCode"`
|
|
ParentID *string `json:"parentId"`
|
|
Name string `json:"name"`
|
|
Quantity *int64 `json:"quantity"`
|
|
}
|
|
|
|
type TreeNode struct {
|
|
Node
|
|
Children []*TreeNode `json:"children"`
|
|
}
|
|
|
|
type ImportNode struct {
|
|
Name string `json:"name"`
|
|
Quantity *int64 `json:"quantity,omitempty"`
|
|
Children []ImportNode `json:"children,omitempty"`
|
|
}
|
|
|
|
type ChildHandling string
|
|
|
|
const (
|
|
WithSubtree ChildHandling = "WITH_SUBTREE"
|
|
PromoteChildren ChildHandling = "PROMOTE_CHILDREN"
|
|
ChildrenToUnsorted ChildHandling = "CHILDREN_TO_UNSORTED"
|
|
)
|
|
|
|
type QuantityUpdate struct {
|
|
Set bool
|
|
Value *int64
|
|
}
|
|
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewStore(db *sql.DB) *Store {
|
|
return &Store{db: db}
|
|
}
|
|
|
|
func (s *Store) Initialize(ctx context.Context) error {
|
|
// A single connection keeps SQLite's connection-local pragmas predictable.
|
|
s.db.SetMaxOpenConns(1)
|
|
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
|
return fmt.Errorf("enable foreign keys: %w", err)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, `PRAGMA journal_mode = WAL`); err != nil {
|
|
return fmt.Errorf("enable WAL: %w", err)
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err = tx.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
id TEXT PRIMARY KEY,
|
|
parent_id TEXT REFERENCES nodes(id) ON DELETE RESTRICT,
|
|
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
|
|
quantity INTEGER CHECK (quantity IS NULL OR quantity >= 0),
|
|
system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0, 1)),
|
|
CHECK (id <> parent_id)
|
|
)`); err != nil {
|
|
return fmt.Errorf("create nodes table: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS nodes_parent_id ON nodes(parent_id)`); err != nil {
|
|
return fmt.Errorf("create parent index: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `
|
|
INSERT INTO nodes (id, parent_id, name, system)
|
|
VALUES (?, NULL, 'Root', 1)
|
|
ON CONFLICT(id) DO NOTHING`, RootID); err != nil {
|
|
return fmt.Errorf("create root: %w", err)
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `
|
|
INSERT INTO nodes (id, parent_id, name, system)
|
|
VALUES (?, ?, 'Unsorted', 1)
|
|
ON CONFLICT(id) DO NOTHING`, UnsortedID, RootID); err != nil {
|
|
return fmt.Errorf("create unsorted: %w", err)
|
|
}
|
|
columns, err := tableColumns(ctx, tx, "nodes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !columns["lookup_code"] {
|
|
if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN lookup_code TEXT`); err != nil {
|
|
return fmt.Errorf("add lookup code: %w", err)
|
|
}
|
|
}
|
|
rows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE lookup_code IS NULL`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var missing []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
missing = append(missing, id)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
for _, id := range missing {
|
|
code, err := newLookupCode(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET lookup_code = ? WHERE id = ?`, code, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS nodes_lookup_code ON nodes(lookup_code)`); err != nil {
|
|
return fmt.Errorf("create lookup code index: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Store) Create(ctx context.Context, parentID *string, name string, quantity *int64) (Node, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || quantity != nil && *quantity < 0 {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
parent := UnsortedID
|
|
if parentID != nil {
|
|
parent = *parentID
|
|
}
|
|
if _, err := s.Get(ctx, parent); err != nil {
|
|
return Node{}, err
|
|
}
|
|
|
|
id, err := newID()
|
|
if err != nil {
|
|
return Node{}, fmt.Errorf("generate node id: %w", err)
|
|
}
|
|
code, err := newLookupCode(ctx, s.db)
|
|
if err != nil {
|
|
return Node{}, fmt.Errorf("generate lookup code: %w", err)
|
|
}
|
|
if _, err = s.db.ExecContext(ctx,
|
|
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
|
|
id, code, parent, name, quantity,
|
|
); err != nil {
|
|
return Node{}, fmt.Errorf("create node: %w", err)
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *Store) CreateMany(ctx context.Context, parentID string, names []string) ([]Node, error) {
|
|
if len(names) == 0 {
|
|
return nil, ErrInvalid
|
|
}
|
|
cleanNames := make([]string, len(names))
|
|
ids := make([]string, len(names))
|
|
for index, name := range names {
|
|
cleanNames[index] = strings.TrimSpace(name)
|
|
if cleanNames[index] == "" {
|
|
return nil, ErrInvalid
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate node id: %w", err)
|
|
}
|
|
ids[index] = id
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := getWith(ctx, tx, parentID); err != nil {
|
|
return nil, err
|
|
}
|
|
for index := range cleanNames {
|
|
code, err := newLookupCode(ctx, tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO nodes (id, lookup_code, parent_id, name) VALUES (?, ?, ?, ?)`,
|
|
ids[index], code, parentID, cleanNames[index],
|
|
); err != nil {
|
|
return nil, fmt.Errorf("create node: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
created := make([]Node, len(cleanNames))
|
|
for index := range cleanNames {
|
|
created[index], err = s.Get(ctx, ids[index])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportNode) (int, error) {
|
|
if len(nodes) == 0 {
|
|
return 0, ErrInvalid
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := getWith(ctx, tx, parentID); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
created := 0
|
|
var insert func(string, []ImportNode) error
|
|
insert = func(parent string, children []ImportNode) error {
|
|
for _, child := range children {
|
|
name := strings.TrimSpace(child.Name)
|
|
if name == "" || child.Quantity != nil && *child.Quantity < 0 {
|
|
return ErrInvalid
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
code, err := newLookupCode(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
|
|
id, code, parent, name, child.Quantity,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
created++
|
|
if err := insert(id, child.Children); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := insert(parentID, nodes); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *Store) Get(ctx context.Context, id string) (Node, error) {
|
|
return scanNode(s.db.QueryRowContext(ctx,
|
|
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id,
|
|
))
|
|
}
|
|
|
|
func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) {
|
|
return scanNode(s.db.QueryRowContext(ctx,
|
|
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE lookup_code = ?`,
|
|
strings.ToUpper(strings.TrimSpace(code)),
|
|
))
|
|
}
|
|
|
|
func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes ORDER BY rowid`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
byID := make(map[string]*TreeNode)
|
|
ordered := make([]*TreeNode, 0)
|
|
for rows.Next() {
|
|
node, err := scanNode(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
treeNode := &TreeNode{Node: node, Children: make([]*TreeNode, 0)}
|
|
byID[node.ID] = treeNode
|
|
ordered = append(ordered, treeNode)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
root := byID[RootID]
|
|
if root == nil {
|
|
return nil, errors.New("root node is missing")
|
|
}
|
|
for _, node := range ordered {
|
|
if node.ID == RootID {
|
|
continue
|
|
}
|
|
if node.ParentID == nil || byID[*node.ParentID] == nil {
|
|
return nil, fmt.Errorf("node %q has an invalid parent", node.ID)
|
|
}
|
|
byID[*node.ParentID].Children = append(byID[*node.ParentID].Children, node)
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
func (s *Store) Update(ctx context.Context, id string, name *string, quantity QuantityUpdate) (Node, error) {
|
|
if name == nil && !quantity.Set {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
if name != nil {
|
|
trimmed := strings.TrimSpace(*name)
|
|
if trimmed == "" {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
name = &trimmed
|
|
if id == RootID || id == UnsortedID {
|
|
return Node{}, ErrConflict
|
|
}
|
|
}
|
|
if quantity.Set && quantity.Value != nil && *quantity.Value < 0 {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
if name != nil {
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ? WHERE id = ?`, *name, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
if quantity.Set {
|
|
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET quantity = ? WHERE id = ?`, quantity.Value, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *Store) Delete(ctx context.Context, id string) error {
|
|
if id == RootID || id == UnsortedID {
|
|
return ErrConflict
|
|
}
|
|
result, err := s.db.ExecContext(ctx, `
|
|
DELETE FROM nodes
|
|
WHERE id = ?
|
|
AND NOT EXISTS (SELECT 1 FROM nodes child WHERE child.parent_id = nodes.id)`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count == 1 {
|
|
return nil
|
|
}
|
|
if _, err := s.Get(ctx, id); errors.Is(err, ErrNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return ErrConflict
|
|
}
|
|
|
|
func (s *Store) DeleteMany(ctx context.Context, ids []string) error {
|
|
if len(ids) == 0 || hasDuplicateIDs(ids) {
|
|
return ErrInvalid
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, id := range ids {
|
|
if id == RootID || id == UnsortedID {
|
|
return ErrConflict
|
|
}
|
|
if _, err := getWith(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
var hasChildren int
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM nodes WHERE parent_id = ?)`, id,
|
|
).Scan(&hasChildren); err != nil {
|
|
return err
|
|
}
|
|
if hasChildren == 1 {
|
|
return ErrConflict
|
|
}
|
|
}
|
|
for _, id := range ids {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Store) Move(ctx context.Context, id, targetParentID string, handling ChildHandling) (Node, error) {
|
|
if handling == "" {
|
|
handling = WithSubtree
|
|
}
|
|
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
|
|
return Node{}, ErrInvalid
|
|
}
|
|
if id == RootID || id == UnsortedID || id == targetParentID {
|
|
return Node{}, ErrConflict
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Node{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
node, err := getWith(ctx, tx, id)
|
|
if err != nil {
|
|
return Node{}, err
|
|
}
|
|
if _, err = getWith(ctx, tx, targetParentID); err != nil {
|
|
return Node{}, err
|
|
}
|
|
cycle, err := isDescendant(ctx, tx, id, targetParentID)
|
|
if err != nil {
|
|
return Node{}, err
|
|
}
|
|
if cycle {
|
|
return Node{}, ErrConflict
|
|
}
|
|
|
|
switch handling {
|
|
case PromoteChildren:
|
|
if node.ParentID == nil {
|
|
return Node{}, ErrConflict
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, *node.ParentID, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
case ChildrenToUnsorted:
|
|
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
}
|
|
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
|
|
return Node{}, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return Node{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *Store) MoveMany(
|
|
ctx context.Context,
|
|
ids []string,
|
|
targetParentID string,
|
|
handling ChildHandling,
|
|
) ([]Node, error) {
|
|
if len(ids) == 0 || hasDuplicateIDs(ids) {
|
|
return nil, ErrInvalid
|
|
}
|
|
if handling == "" {
|
|
handling = WithSubtree
|
|
}
|
|
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
|
|
return nil, ErrInvalid
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := getWith(ctx, tx, targetParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes := make([]Node, len(ids))
|
|
var commonParent string
|
|
for index, id := range ids {
|
|
if id == RootID || id == UnsortedID || id == targetParentID {
|
|
return nil, ErrConflict
|
|
}
|
|
node, err := getWith(ctx, tx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if node.ParentID == nil {
|
|
return nil, ErrConflict
|
|
}
|
|
if index == 0 {
|
|
commonParent = *node.ParentID
|
|
} else if *node.ParentID != commonParent {
|
|
return nil, ErrConflict
|
|
}
|
|
cycle, err := isDescendant(ctx, tx, id, targetParentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cycle {
|
|
return nil, ErrConflict
|
|
}
|
|
nodes[index] = node
|
|
}
|
|
|
|
for index, id := range ids {
|
|
switch handling {
|
|
case PromoteChildren:
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, commonParent, id); err != nil {
|
|
return nil, err
|
|
}
|
|
case ChildrenToUnsorted:
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
|
|
return nil, err
|
|
}
|
|
nodes[index].ParentID = &targetParentID
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nodes, nil
|
|
}
|
|
|
|
type scanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanNode(row scanner) (Node, error) {
|
|
var node Node
|
|
var parent sql.NullString
|
|
var quantity sql.NullInt64
|
|
if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &quantity); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Node{}, ErrNotFound
|
|
}
|
|
return Node{}, err
|
|
}
|
|
if parent.Valid {
|
|
node.ParentID = &parent.String
|
|
}
|
|
if quantity.Valid {
|
|
node.Quantity = &quantity.Int64
|
|
}
|
|
return node, nil
|
|
}
|
|
|
|
func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) {
|
|
return scanNode(tx.QueryRowContext(ctx,
|
|
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id))
|
|
}
|
|
|
|
func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) {
|
|
var found int
|
|
err := tx.QueryRowContext(ctx, `
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT id FROM nodes WHERE parent_id = ?
|
|
UNION ALL
|
|
SELECT nodes.id FROM nodes JOIN descendants ON nodes.parent_id = descendants.id
|
|
)
|
|
SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?)`, ancestorID, candidateID).Scan(&found)
|
|
return found == 1, err
|
|
}
|
|
|
|
func newID() (string, error) {
|
|
var value [16]byte
|
|
if _, err := rand.Read(value[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(value[:]), nil
|
|
}
|
|
|
|
type queryer interface {
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}
|
|
|
|
func newLookupCode(ctx context.Context, db queryer) (string, error) {
|
|
for attempt := 0; attempt < 20; attempt++ {
|
|
var value [5]byte
|
|
if _, err := rand.Read(value[:]); err != nil {
|
|
return "", err
|
|
}
|
|
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value[:])[:6]
|
|
var exists int
|
|
if err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE lookup_code = ?)`, code).Scan(&exists); err != nil {
|
|
return "", err
|
|
}
|
|
if exists == 0 {
|
|
return code, nil
|
|
}
|
|
}
|
|
return "", errors.New("could not generate unique lookup code")
|
|
}
|
|
|
|
func tableColumns(ctx context.Context, tx *sql.Tx, table string) (map[string]bool, error) {
|
|
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
columns := make(map[string]bool)
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, columnType string
|
|
var notNull, primaryKey int
|
|
var defaultValue any
|
|
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
|
return nil, err
|
|
}
|
|
columns[name] = true
|
|
}
|
|
return columns, rows.Err()
|
|
}
|
|
|
|
func hasDuplicateIDs(ids []string) bool {
|
|
seen := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if strings.TrimSpace(id) == "" {
|
|
return true
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
return true
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
return false
|
|
}
|