From c8cbb72ed7b28207bf9d3f2b06d9de374193aec4 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sat, 7 Mar 2026 11:03:48 +0000 Subject: [PATCH] feat(api): add Delete and Sub methods, update CI workflows - Add Delete(key) to remove keys from all config layers - Add Sub(key) to get a scoped ConfigManager for nested sections - Update CodeQL workflow actions to v4/v5/v3 - Update govulncheck workflow checkout to v4 - Remove boilerplate comments from CodeQL workflow - Add comprehensive tests for both new methods - Update README with Sub() usage example and API docs --- .github/workflows/codeql-analysis.yml | 21 +--- .github/workflows/govulncheck.yml | 2 +- README.md | 21 +++- default.go | 8 ++ jety.go | 25 ++++ jety_test.go | 167 ++++++++++++++++++++++++++ setters.go | 13 ++ 7 files changed, 235 insertions(+), 22 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d55a02b..9af6eff 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,31 +36,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 54eba17..1e44acf 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest name: Run govulncheck steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - id: govulncheck uses: golang/govulncheck-action@v1 with: diff --git a/README.md b/README.md index 93f2977..f4d2a24 100644 --- a/README.md +++ b/README.md @@ -68,16 +68,25 @@ timeout = "30s" client_id = "abc123" ``` -Access nested values using `GetStringMap` and type assertions: +Use `Sub()` to get a scoped ConfigManager for a nested section: + +```go +cloud := jety.Sub("services") +if cloud != nil { + inner := cloud.Sub("cloud") + if inner != nil { + varValue := inner.GetString("var") // "xyz" + timeout := inner.GetDuration("timeout") // 30s + } +} +``` + +Or access nested values directly with `GetStringMap` and type assertions: ```go services := jety.GetStringMap("services") cloud := services["cloud"].(map[string]any) varValue := cloud["var"].(string) // "xyz" - -// For deeper nesting -auth := cloud["auth"].(map[string]any) -clientID := auth["client_id"].(string) // "abc123" ``` ### Environment Variable Overrides @@ -124,6 +133,8 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value | ------------------------ | ------------------------ | | `Set(key, value)` | Set a value | | `SetDefault(key, value)` | Set a default value | +| `Delete(key)` | Remove a key | +| `Sub(key)` | Get scoped sub-config | | `Get(key)` | Get raw value | | `GetString(key)` | Get as string | | `GetInt(key)` | Get as int | diff --git a/default.go b/default.go index d042f5a..433371b 100644 --- a/default.go +++ b/default.go @@ -88,6 +88,14 @@ func SetString(key string, value string) { defaultConfigManager.SetString(key, value) } +func Delete(key string) { + defaultConfigManager.Delete(key) +} + +func Sub(key string) *ConfigManager { + return defaultConfigManager.Sub(key) +} + func SetConfigDir(path string) { defaultConfigManager.SetConfigDir(path) } diff --git a/jety.go b/jety.go index 124c587..62cbf8d 100644 --- a/jety.go +++ b/jety.go @@ -308,6 +308,31 @@ func readFile(filename string, fileType configType) (map[string]any, error) { } } +// Sub returns a new ConfigManager rooted at the given key. The key must +// refer to a map value (e.g., from a nested TOML/YAML/JSON section). +// The returned ConfigManager has the nested map loaded as its default +// config, and does not inherit the parent's environment prefix, overrides, +// or config file. Returns nil if the key does not exist or is not a map. +func (c *ConfigManager) Sub(key string) *ConfigManager { + c.mutex.RLock() + defer c.mutex.RUnlock() + v, ok := c.resolve(key) + if !ok { + return nil + } + m, ok := v.Value.(map[string]any) + if !ok { + return nil + } + sub := NewConfigManager() + for k, val := range m { + lower := strings.ToLower(k) + sub.defaultConfig[lower] = ConfigMap{Key: k, Value: val} + sub.combinedConfig[lower] = ConfigMap{Key: k, Value: val} + } + return sub +} + func (c *ConfigManager) SetConfigDir(path string) { c.mutex.Lock() defer c.mutex.Unlock() diff --git a/jety_test.go b/jety_test.go index 4e7edb3..8f68b6c 100644 --- a/jety_test.go +++ b/jety_test.go @@ -1554,3 +1554,170 @@ func TestPackageLevelGetInt64(t *testing.T) { t.Errorf("GetInt64(bignum) = %d, want 9223372036854775807", got) } } + +func TestDelete(t *testing.T) { + cm := NewConfigManager() + cm.SetDefault("keep", "yes") + cm.SetDefault("remove", "default") + cm.Set("remove", "override") + + if !cm.IsSet("remove") { + t.Fatal("remove should be set before delete") + } + + cm.Delete("remove") + + if cm.IsSet("remove") { + t.Error("remove should not be set after delete") + } + if cm.Get("remove") != nil { + t.Error("Get(remove) should return nil after delete") + } + if cm.GetString("keep") != "yes" { + t.Error("keep should be unaffected by deleting remove") + } +} + +func TestDeleteFromAllLayers(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configFile, []byte(`filekey = "fromfile"`), 0o644); err != nil { + t.Fatal(err) + } + + cm := NewConfigManager() + cm.SetConfigFile(configFile) + if err := cm.SetConfigType("toml"); err != nil { + t.Fatal(err) + } + cm.SetDefault("filekey", "default") + if err := cm.ReadInConfig(); err != nil { + t.Fatal(err) + } + cm.Set("filekey", "override") + + if cm.GetString("filekey") != "override" { + t.Fatal("expected override value before delete") + } + + cm.Delete("filekey") + + if cm.IsSet("filekey") { + t.Error("filekey should not be set after delete from all layers") + } +} + +func TestDeleteCaseInsensitive(t *testing.T) { + cm := NewConfigManager() + cm.Set("MyKey", "value") + + cm.Delete("MYKEY") + + if cm.IsSet("mykey") { + t.Error("delete should be case-insensitive") + } +} + +func TestPackageLevelDelete(t *testing.T) { + defaultConfigManager = NewConfigManager() + Set("temp", "value") + if !IsSet("temp") { + t.Fatal("temp should be set") + } + Delete("temp") + if IsSet("temp") { + t.Error("temp should not be set after package-level Delete") + } +} + +func TestSub(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configFile, []byte(tomlConfig), 0o644); err != nil { + t.Fatal(err) + } + + cm := NewConfigManager() + cm.SetConfigFile(configFile) + if err := cm.SetConfigType("toml"); err != nil { + t.Fatal(err) + } + if err := cm.ReadInConfig(); err != nil { + t.Fatal(err) + } + + sub := cm.Sub("database") + if sub == nil { + t.Fatal("Sub(database) returned nil") + } + + host := sub.GetString("host") + if host != "db.example.com" { + t.Errorf("Sub(database).GetString(host) = %q, want %q", host, "db.example.com") + } + + port := sub.GetInt("port") + if port != 5432 { + t.Errorf("Sub(database).GetInt(port) = %d, want 5432", port) + } +} + +func TestSubNonExistentKey(t *testing.T) { + cm := NewConfigManager() + cm.SetDefault("simple", "value") + + sub := cm.Sub("nonexistent") + if sub != nil { + t.Error("Sub(nonexistent) should return nil") + } +} + +func TestSubNonMapKey(t *testing.T) { + cm := NewConfigManager() + cm.Set("name", "plain-string") + + sub := cm.Sub("name") + if sub != nil { + t.Error("Sub on a non-map key should return nil") + } +} + +func TestSubIsIndependent(t *testing.T) { + cm := NewConfigManager() + cm.Set("section", map[string]any{ + "key1": "val1", + "key2": "val2", + }) + + sub := cm.Sub("section") + if sub == nil { + t.Fatal("Sub(section) returned nil") + } + + // Modifying sub should not affect parent + sub.Set("key1", "modified") + if sub.GetString("key1") != "modified" { + t.Error("sub should reflect the Set") + } + + parentSection := cm.Get("section").(map[string]any) + if parentSection["key1"] != "val1" { + t.Error("modifying sub should not affect parent") + } +} + +func TestPackageLevelSub(t *testing.T) { + defaultConfigManager = NewConfigManager() + Set("db", map[string]any{"host": "localhost", "port": 5432}) + + sub := Sub("db") + if sub == nil { + t.Fatal("package-level Sub returned nil") + } + if sub.GetString("host") != "localhost" { + t.Errorf("Sub(db).GetString(host) = %q, want %q", sub.GetString("host"), "localhost") + } + if sub.GetInt("port") != 5432 { + t.Errorf("Sub(db).GetInt(port) = %d, want 5432", sub.GetInt("port")) + } +} diff --git a/setters.go b/setters.go index ebaf3d9..553ef66 100644 --- a/setters.go +++ b/setters.go @@ -28,6 +28,19 @@ func (c *ConfigManager) Set(key string, value any) { c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} } +// Delete removes a key from all configuration layers (overrides, file, +// defaults) and rebuilds the combined configuration. Environment variables +// are not affected since they are loaded from the process environment. +func (c *ConfigManager) Delete(key string) { + c.mutex.Lock() + lower := strings.ToLower(key) + delete(c.overrideConfig, lower) + delete(c.fileConfig, lower) + delete(c.defaultConfig, lower) + delete(c.combinedConfig, lower) + c.mutex.Unlock() +} + func (c *ConfigManager) SetDefault(key string, value any) { c.mutex.Lock() defer c.mutex.Unlock()