redis-client-namespace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b42761a140eeca3fe1fc46feaf42636faafde11bec1aa8144f055c314686806
4
+ data.tar.gz: 795aa14bf6b0616886b6231fedffd2f23c60901d452f204824a0d1523587a4b1
5
+ SHA512:
6
+ metadata.gz: 02cc98329107c65ac11d1e851b277eea83cb520741c82e8d90f8275d4b943163fdc5a7b58806d73f7f4c68151b1b32437050b32b57f8432566a24ad339d95802
7
+ data.tar.gz: a59cdff0ae554c69cd060a4bfab2062c0e088352b6ffe0afff082c2f8df1ad85ddd93be233588f9c1b61052bbdfd44e6ae0d0596e7652ad5f99a04b9e30a0821
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,64 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - 'bin/*'
7
+ - 'vendor/**/*'
8
+ - 'tmp/**/*'
9
+
10
+ Style/StringLiterals:
11
+ EnforcedStyle: double_quotes
12
+
13
+ Style/StringLiteralsInInterpolation:
14
+ EnforcedStyle: double_quotes
15
+
16
+ # Command builder module is inherently large due to Redis command mapping
17
+ Metrics/ModuleLength:
18
+ Exclude:
19
+ - 'lib/redis_client/namespace/command_builder.rb'
20
+
21
+ # Spec files and constant definitions can have long blocks
22
+ Metrics/BlockLength:
23
+ Exclude:
24
+ - 'spec/**/*'
25
+ - 'lib/redis_client/namespace/command_builder.rb'
26
+
27
+ # Documentation is good but not required for internal modules
28
+ Style/Documentation:
29
+ Exclude:
30
+ - 'spec/**/*'
31
+ - 'lib/redis_client/namespace/command_builder.rb'
32
+
33
+ # Prefer explicit array syntax in specs for readability
34
+ Style/WordArray:
35
+ Exclude:
36
+ - 'spec/**/*'
37
+
38
+ # Allow longer lines in specs
39
+ Layout/LineLength:
40
+ Max: 120
41
+ Exclude:
42
+ - 'spec/**/*'
43
+
44
+ # Entry point file name matches gem name
45
+ Naming/FileName:
46
+ Exclude:
47
+ - 'lib/redis-client-namespace.rb'
48
+
49
+ # Test helper method for processing Redis commands.json - complexity is justified
50
+ Metrics/AbcSize:
51
+ Exclude:
52
+ - 'spec/redis_client/namespace/command_builder_auto_spec.rb'
53
+
54
+ Metrics/CyclomaticComplexity:
55
+ Exclude:
56
+ - 'spec/redis_client/namespace/command_builder_auto_spec.rb'
57
+
58
+ Metrics/MethodLength:
59
+ Exclude:
60
+ - 'spec/redis_client/namespace/command_builder_auto_spec.rb'
61
+
62
+ Metrics/PerceivedComplexity:
63
+ Exclude:
64
+ - 'spec/redis_client/namespace/command_builder_auto_spec.rb'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-07-26
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Kensaku Araga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # RedisClient::Namespace
2
+
3
+ A Redis namespace extension for [redis-client](https://github.com/redis-rb/redis-client) gem that automatically prefixes Redis keys with a namespace, enabling multi-tenancy and key isolation in Redis applications.
4
+
5
+ This gem works by wrapping `RedisClient::CommandBuilder` and intercepting Redis commands to transparently add namespace prefixes to keys before they are sent to Redis.
6
+
7
+ ## Motivation
8
+
9
+ This gem was created to provide namespace support for [Sidekiq](https://github.com/sidekiq/sidekiq) applications using the `redis-client` gem. As Sidekiq migrates from the `redis` gem to `redis-client`, there was a need for a namespace solution that works seamlessly with the new client architecture while maintaining compatibility with existing namespace-based deployments.
10
+
11
+ ## Features
12
+
13
+ - **Transparent key namespacing**: Automatically prefixes Redis keys with a configurable namespace
14
+ - **Comprehensive command support**: Supports all Redis commands with intelligent key detection
15
+ - **Customizable separator**: Configure the namespace separator (default: `:`)
16
+ - **Nested namespaces**: Support for nested command builders with multiple namespace levels
17
+ - **Zero configuration**: Works out of the box with sensible defaults
18
+ - **High performance**: Minimal overhead with efficient command transformation
19
+ - **Thread-safe**: Safe for use in multi-threaded applications
20
+
21
+ ## Installation
22
+
23
+ Add this line to your application's Gemfile:
24
+
25
+ ```ruby
26
+ gem 'redis-client-namespace'
27
+ ```
28
+
29
+ And then execute:
30
+
31
+ ```bash
32
+ bundle install
33
+ ```
34
+
35
+ Or install it yourself as:
36
+
37
+ ```bash
38
+ gem install redis-client-namespace
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Basic Usage
44
+
45
+ ```ruby
46
+ require 'redis-client-namespace'
47
+
48
+ # Create a namespaced command builder
49
+ namespace = RedisClient::Namespace.new("myapp")
50
+
51
+ # Use with redis-client
52
+ client = RedisClient.config(command_builder: namespace).new_client
53
+
54
+ # All commands will be automatically namespaced
55
+ client.call("SET", "user:123", "john") # Actually sets "myapp:user:123"
56
+ client.call("GET", "user:123") # Actually gets "myapp:user:123"
57
+ client.call("DEL", "user:123", "user:456") # Actually deletes "myapp:user:123", "myapp:user:456"
58
+ ```
59
+
60
+ ### Custom Separator
61
+
62
+ ```ruby
63
+ # Use a custom separator
64
+ namespace = RedisClient::Namespace.new("myapp", separator: "-")
65
+ client = RedisClient.config(command_builder: namespace).new_client
66
+
67
+ client.call("SET", "user:123", "john") # Actually sets "myapp-user:123"
68
+ ```
69
+
70
+ ### Nested Namespaces
71
+
72
+ ```ruby
73
+ # Create nested namespaces
74
+ parent = RedisClient::Namespace.new("myapp")
75
+ child = RedisClient::Namespace.new("jobs", parent_command_builder: parent)
76
+
77
+ client = RedisClient.config(command_builder: child).new_client
78
+ client.call("SET", "queue", "important") # Actually sets "jobs:myapp:queue"
79
+ ```
80
+
81
+ ### Sidekiq Integration
82
+
83
+ This gem is particularly useful for Sidekiq applications that need namespace isolation:
84
+
85
+ ```ruby
86
+ # In your Sidekiq configuration
87
+ require 'redis-client-namespace'
88
+
89
+ namespace = RedisClient::Namespace.new("sidekiq_production")
90
+
91
+ Sidekiq.configure_server do |config|
92
+ config.redis = {
93
+ url: 'redis://redis:6379/1',
94
+ command_builder: namespace,
95
+ }
96
+ end
97
+
98
+ Sidekiq.configure_client do |config|
99
+ config.redis = {
100
+ url: 'redis://redis:6379/1',
101
+ command_builder: namespace,
102
+ }
103
+ end
104
+ ```
105
+
106
+ ## Supported Redis Commands
107
+
108
+ RedisClient::Namespace supports the vast majority of Redis commands with intelligent key transformation:
109
+
110
+ - **String commands**: `GET`, `SET`, `MGET`, `MSET`, etc.
111
+ - **List commands**: `LPUSH`, `RPOP`, `LRANGE`, etc.
112
+ - **Set commands**: `SADD`, `SREM`, `SINTER`, etc.
113
+ - **Sorted Set commands**: `ZADD`, `ZCOUNT`, `ZRANGE`, etc.
114
+ - **Hash commands**: `HGET`, `HSET`, `HDEL`, etc.
115
+ - **Stream commands**: `XADD`, `XREAD`, `XGROUP`, etc.
116
+ - **Pub/Sub commands**: `PUBLISH`, `SUBSCRIBE`, etc.
117
+ - **Scripting commands**: `EVAL`, `EVALSHA` with proper key handling
118
+ - **Transaction commands**: `WATCH`, `MULTI`, `EXEC`
119
+ - **And many more...**
120
+
121
+ The gem automatically detects which arguments are keys and applies the namespace prefix accordingly.
122
+
123
+ ## Advanced Features
124
+
125
+ ### Pattern Matching
126
+
127
+ For commands like `SCAN` and `KEYS`, the namespace is automatically applied to patterns:
128
+
129
+ ```ruby
130
+ namespace = RedisClient::Namespace.new("myapp")
131
+ client = RedisClient.config(command_builder: namespace).new_client
132
+
133
+ # This will scan for "myapp:user:*" pattern
134
+ client.call("SCAN", 0, "MATCH", "user:*")
135
+ ```
136
+
137
+ ### Complex Commands
138
+
139
+ The gem handles complex commands with multiple keys intelligently:
140
+
141
+ ```ruby
142
+ # SORT command with BY and GET options
143
+ client.call("SORT", "list", "BY", "weight_*", "GET", "object_*", "STORE", "result")
144
+ # Becomes: SORT myapp:list BY myapp:weight_* GET myapp:object_* STORE myapp:result
145
+
146
+ # Lua scripts with proper key handling
147
+ client.call("EVAL", "return redis.call('get', KEYS[1])", 1, "mykey")
148
+ # The key "mykey" becomes "myapp:mykey"
149
+ ```
150
+
151
+ ## Configuration Options
152
+
153
+ - `namespace`: The namespace prefix to use (required)
154
+ - `separator`: The separator between namespace and key (default: `":"`)
155
+ - `parent_command_builder`: Parent command builder for nested namespaces (default: `RedisClient::CommandBuilder`)
156
+
157
+ ## Thread Safety
158
+
159
+ RedisClient::Namespace is **thread-safe** and can be used in multi-threaded applications without additional synchronization. The implementation:
160
+
161
+ - Uses immutable instance variables (`@namespace`, `@separator`, `@parent_command_builder`) that are set once during initialization
162
+ - Never modifies shared state during command processing
163
+ - Creates new command arrays for each operation without mutating the original
164
+ - Uses frozen constants for strategy and command mappings
165
+
166
+ Each `generate` call is completely independent, making it safe to use the same namespace instance across multiple threads.
167
+
168
+ ## Performance
169
+
170
+ The gem adds minimal overhead to Redis operations. Command transformation is performed efficiently with optimized strategies for different command patterns.
171
+
172
+ ## Development
173
+
174
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`.
175
+
176
+ ### Local Testing with Redis
177
+
178
+ For local development and testing, you can use Docker Compose to run Redis:
179
+
180
+ ```bash
181
+ # Start Redis on port 16379
182
+ docker compose up -d
183
+
184
+ # Run tests against the local Redis instance
185
+ REDIS_PORT=16379 bundle exec rake
186
+
187
+ # Stop Redis when done
188
+ docker compose down
189
+ ```
190
+
191
+ ## Testing
192
+
193
+ The gem includes comprehensive tests covering Redis commands. Our test suite uses the official [Redis commands.json](https://github.com/redis/docs/blob/main/data/commands.json) specification to ensure broad coverage of Redis commands and their key transformation strategies:
194
+
195
+ - **Automated command coverage**: Tests are generated from Redis's official command specification
196
+ - **Manual edge case testing**: Complex commands like `SORT`, `EVAL`, and `MIGRATE` have dedicated test suites
197
+ - **Wide Redis compatibility**: Supports the vast majority of Redis commands with proper key handling
198
+
199
+ ```bash
200
+ bundle exec rspec
201
+ ```
202
+
203
+ This approach helps RedisClient::Namespace stay compatible with Redis command changes and maintain compatibility with most Redis operations.
204
+
205
+ ## Contributing
206
+
207
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ken39arg/redis-client-namespace. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ken39arg/redis-client-namespace/blob/main/CODE_OF_CONDUCT.md).
208
+
209
+ ## License
210
+
211
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
212
+
213
+ ## Code of Conduct
214
+
215
+ Everyone interacting in the RedisClient::Namespace project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ken39arg/redis-client-namespace/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/compose.yml ADDED
@@ -0,0 +1,6 @@
1
+ services:
2
+ redis:
3
+ image: redis:8.0
4
+ ports:
5
+ - "16379:6379"
6
+ command: redis-server --appendonly yes
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redis_client/namespace"
@@ -0,0 +1,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ class Namespace
5
+ module CommandBuilder
6
+ # Namespace transformation strategies
7
+ STRATEGIES = {
8
+ # Basic common strategies
9
+ none: ->(cmd, &block) {}, # No transformation
10
+ all: ->(cmd, &block) { cmd.drop(1).each_with_index { |key, i| cmd[i + 1] = block.call(key) } },
11
+ first: ->(cmd, &block) { cmd[1] = block.call(cmd[1]) if cmd[1] },
12
+ second: ->(cmd, &block) { cmd[2] = block.call(cmd[2]) if cmd[2] },
13
+ first_two: lambda { |cmd, &block|
14
+ cmd[1] = block.call(cmd[1]) if cmd[1]
15
+ cmd[2] = block.call(cmd[2]) if cmd[2]
16
+ },
17
+ exclude_first: ->(cmd, &block) { cmd.drop(2).each_with_index { |key, i| cmd[i + 2] = block.call(key) } },
18
+ exclude_last: lambda { |cmd, &block|
19
+ return if cmd.size < 3
20
+
21
+ (1...(cmd.size - 1)).each { |i| cmd[i] = block.call(cmd[i]) }
22
+ },
23
+ alternate: lambda { |cmd, &block|
24
+ cmd.drop(1).each_with_index do |item, i|
25
+ cmd[i + 1] = block.call(item) if i.even?
26
+ end
27
+ },
28
+
29
+ # Custom strategies used by multiple commands
30
+ eval_style: lambda { |cmd, &block|
31
+ return if cmd.size < 3
32
+
33
+ numkeys = cmd[2].to_i
34
+ actual_keys = [numkeys, cmd.size - 3].min
35
+ actual_keys.times { |i| cmd[3 + i] = block.call(cmd[3 + i]) if cmd[3 + i] }
36
+ },
37
+
38
+ # Single-command specific strategies
39
+ sort: lambda { |cmd, &block|
40
+ cmd[1] = block.call(cmd[1]) if cmd[1]
41
+ # Handle BY, GET, STORE options
42
+ cmd.each_with_index do |arg, i|
43
+ next if i.zero?
44
+
45
+ case arg.to_s.upcase
46
+ when "BY", "STORE"
47
+ cmd[i + 1] = block.call(cmd[i + 1]) if cmd[i + 1]
48
+ when "GET"
49
+ # GET can be "#" or a pattern
50
+ cmd[i + 1] = block.call(cmd[i + 1]) if cmd[i + 1] && cmd[i + 1] != "#"
51
+ end
52
+ end
53
+ },
54
+ georadius_style: lambda { |cmd, &block|
55
+ cmd[1] = block.call(cmd[1]) if cmd[1]
56
+ # Handle STORE, STOREDIST options
57
+ cmd.each_with_index do |arg, i|
58
+ if (arg.to_s.casecmp("STORE").zero? || arg.to_s.casecmp("STOREDIST").zero?) && cmd[i + 1]
59
+ cmd[i + 1] = block.call(cmd[i + 1])
60
+ end
61
+ end
62
+ },
63
+ xread_style: lambda { |cmd, &block|
64
+ # Find STREAMS keyword
65
+ streams_idx = cmd.index { |arg| arg.to_s.casecmp("STREAMS").zero? }
66
+ return unless streams_idx
67
+
68
+ # Transform keys after STREAMS
69
+ num_keys = (cmd.size - streams_idx - 1) / 2
70
+ num_keys.times do |i|
71
+ key_idx = streams_idx + 1 + i
72
+ cmd[key_idx] = block.call(cmd[key_idx]) if cmd[key_idx]
73
+ end
74
+ },
75
+ migrate: lambda { |cmd, &block|
76
+ # MIGRATE host port key destination-db timeout [options]
77
+ # MIGRATE host port "" destination-db timeout [COPY | REPLACE] KEYS key [key ...]
78
+ if cmd[3] && cmd[3] != ""
79
+ # Single key format
80
+ cmd[3] = block.call(cmd[3])
81
+ elsif (keys_idx = cmd.index { |arg| arg.to_s.casecmp("KEYS").zero? })
82
+ # Multiple keys format - transform keys after KEYS keyword
83
+ ((keys_idx + 1)...cmd.size).each do |i|
84
+ cmd[i] = block.call(cmd[i]) if cmd[i]
85
+ end
86
+ end
87
+ },
88
+ zinterstore_style: lambda { |cmd, &block|
89
+ # ZINTERSTORE destination numkeys key [key ...]
90
+ return if cmd.size < 3
91
+
92
+ cmd[1] = block.call(cmd[1]) if cmd[1] # destination
93
+
94
+ numkeys = cmd[2].to_i
95
+ actual_keys = [numkeys, cmd.size - 3].min
96
+ actual_keys.times do |i|
97
+ key_idx = 3 + i
98
+ cmd[key_idx] = block.call(cmd[key_idx]) if cmd[key_idx]
99
+ end
100
+ },
101
+ blmpop_style: lambda { |cmd, &block|
102
+ # BLMPOP timeout numkeys key [key ...] <LEFT | RIGHT> [COUNT count]
103
+ return if cmd.size < 4
104
+
105
+ numkeys = cmd[2].to_i
106
+ actual_keys = [numkeys, cmd.size - 3].min
107
+ actual_keys.times do |i|
108
+ key_idx = 3 + i
109
+ cmd[key_idx] = block.call(cmd[key_idx]) if cmd[key_idx]
110
+ end
111
+ },
112
+ lmpop_style: lambda { |cmd, &block|
113
+ # LMPOP numkeys key [key ...] <LEFT | RIGHT> [COUNT count]
114
+ return if cmd.size < 3
115
+
116
+ numkeys = cmd[1].to_i
117
+ actual_keys = [numkeys, cmd.size - 2].min
118
+ actual_keys.times do |i|
119
+ key_idx = 2 + i
120
+ cmd[key_idx] = block.call(cmd[key_idx]) if cmd[key_idx]
121
+ end
122
+ },
123
+ scan_cursor_style: lambda { |cmd, &block|
124
+ # SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
125
+ # Only transform MATCH pattern if present and command is SCAN
126
+ if cmd[0].to_s.casecmp("SCAN").zero? && (match_idx = cmd.index do |arg|
127
+ arg.to_s.casecmp("MATCH").zero?
128
+ end) && cmd[match_idx + 1]
129
+ cmd[match_idx + 1] = block.call(cmd[match_idx + 1])
130
+ end
131
+ },
132
+ pubsub_style: lambda { |cmd, &block|
133
+ # PUBSUB CHANNELS [pattern]
134
+ # PUBSUB NUMSUB [channel [channel ...]]
135
+ # PUBSUB SHARDCHANNELS [pattern]
136
+ # PUBSUB SHARDNUMSUB [shardchannel [shardchannel ...]]
137
+ return if cmd.size < 2
138
+
139
+ subcommand = cmd[1].to_s.upcase
140
+ case subcommand
141
+ when "CHANNELS", "SHARDCHANNELS"
142
+ # Transform pattern if present
143
+ cmd[2] = block.call(cmd[2]) if cmd[2]
144
+ when "NUMSUB", "SHARDNUMSUB"
145
+ # Transform all channels starting from index 2
146
+ (2...cmd.size).each do |i|
147
+ cmd[i] = block.call(cmd[i]) if cmd[i]
148
+ end
149
+ # NUMPAT has no channels to transform
150
+ end
151
+ },
152
+ scan_style: lambda { |cmd, &block|
153
+ # HSCAN/SSCAN/ZSCAN key cursor [MATCH pattern] [COUNT count]
154
+ # First argument is the key, don't transform MATCH pattern for HSCAN/SSCAN/ZSCAN
155
+ cmd[1] = block.call(cmd[1]) if cmd[1]
156
+ },
157
+ memory_usage: lambda { |cmd, &block|
158
+ # MEMORY USAGE key [SAMPLES samples]
159
+ cmd[2] = block.call(cmd[2]) if cmd.size >= 3 && cmd[1].to_s.casecmp("USAGE").zero? && cmd[2]
160
+ }
161
+ }.freeze
162
+
163
+ # Command to strategy mapping (inspired by redis-namespace)
164
+ COMMANDS = {
165
+ # Generic
166
+ "DEL" => :all,
167
+ "EXISTS" => :all,
168
+ "EXPIRE" => :first,
169
+ "EXPIREAT" => :first,
170
+ "KEYS" => :first,
171
+ "MOVE" => :first,
172
+ "PERSIST" => :first,
173
+ "PEXPIRE" => :first,
174
+ "PEXPIREAT" => :first,
175
+ "PTTL" => :first,
176
+ "RANDOMKEY" => :none,
177
+ "RENAME" => :first_two,
178
+ "RENAMENX" => :first_two,
179
+ "RESTORE" => :first,
180
+ "TTL" => :first,
181
+ "TYPE" => :first,
182
+ "UNLINK" => :all,
183
+ "SCAN" => :scan_cursor_style,
184
+ "DUMP" => :first,
185
+ "COPY" => :first_two,
186
+ "MIGRATE" => :migrate,
187
+ "SORT" => :sort,
188
+ "SORT_RO" => :sort,
189
+ "TOUCH" => :all,
190
+ "WAIT" => :none,
191
+ "WAITAOF" => :none,
192
+ "OBJECT" => :second,
193
+ "RESTORE-ASKING" => :first,
194
+ "EXPIRETIME" => :first,
195
+ "PEXPIRETIME" => :first,
196
+
197
+ # Bitmap
198
+ "BITCOUNT" => :first,
199
+ "BITOP" => :exclude_first,
200
+ "BITPOS" => :first,
201
+ "BITFIELD" => :first,
202
+ "BITFIELD_RO" => :first,
203
+ "GETBIT" => :first,
204
+ "SETBIT" => :first,
205
+
206
+ # String
207
+ "APPEND" => :first,
208
+ "DECR" => :first,
209
+ "DECRBY" => :first,
210
+ "GET" => :first,
211
+ "GETRANGE" => :first,
212
+ "GETSET" => :first,
213
+ "INCR" => :first,
214
+ "INCRBY" => :first,
215
+ "INCRBYFLOAT" => :first,
216
+ "MGET" => :all,
217
+ "MSET" => :alternate,
218
+ "MSETNX" => :alternate,
219
+ "PSETEX" => :first,
220
+ "SET" => :first,
221
+ "SETEX" => :first,
222
+ "SETNX" => :first,
223
+ "SETRANGE" => :first,
224
+ "STRLEN" => :first,
225
+ "GETDEL" => :first,
226
+ "GETEX" => :first,
227
+ "LCS" => :first_two,
228
+ "SUBSTR" => :first,
229
+
230
+ # List
231
+ "BLPOP" => :exclude_last,
232
+ "BRPOP" => :exclude_last,
233
+ "BRPOPLPUSH" => :first_two,
234
+ "LINDEX" => :first,
235
+ "LINSERT" => :first,
236
+ "LLEN" => :first,
237
+ "LPOP" => :first,
238
+ "LPUSH" => :first,
239
+ "LPUSHX" => :first,
240
+ "LRANGE" => :first,
241
+ "LREM" => :first,
242
+ "LSET" => :first,
243
+ "LTRIM" => :first,
244
+ "RPOP" => :first,
245
+ "RPOPLPUSH" => :first_two,
246
+ "RPUSH" => :first,
247
+ "RPUSHX" => :first,
248
+ "LMOVE" => :first_two,
249
+ "BLMOVE" => :first_two,
250
+ "LMPOP" => :lmpop_style,
251
+ "BLMPOP" => :blmpop_style,
252
+ "LPOS" => :first,
253
+
254
+ # Set
255
+ "SADD" => :first,
256
+ "SCARD" => :first,
257
+ "SDIFF" => :all,
258
+ "SDIFFSTORE" => :all,
259
+ "SINTER" => :all,
260
+ "SINTERSTORE" => :all,
261
+ "SISMEMBER" => :first,
262
+ "SMEMBERS" => :first,
263
+ "SMISMEMBER" => :first,
264
+ "SMOVE" => :first_two,
265
+ "SPOP" => :first,
266
+ "SRANDMEMBER" => :first,
267
+ "SREM" => :first,
268
+ "SUNION" => :all,
269
+ "SUNIONSTORE" => :all,
270
+ "SSCAN" => :scan_style,
271
+ "SINTERCARD" => :lmpop_style,
272
+
273
+ # Sorted-set
274
+ "BZPOPMIN" => :exclude_last,
275
+ "BZPOPMAX" => :exclude_last,
276
+ "ZADD" => :first,
277
+ "ZCARD" => :first,
278
+ "ZCOUNT" => :first,
279
+ "ZINCRBY" => :first,
280
+ "ZINTERSTORE" => :zinterstore_style,
281
+ "ZLEXCOUNT" => :first,
282
+ "ZPOPMAX" => :first,
283
+ "ZPOPMIN" => :first,
284
+ "ZRANGE" => :first,
285
+ "ZRANGEBYLEX" => :first,
286
+ "ZREVRANGEBYLEX" => :first,
287
+ "ZRANGEBYSCORE" => :first,
288
+ "ZRANK" => :first,
289
+ "ZREM" => :first,
290
+ "ZREMRANGEBYLEX" => :first,
291
+ "ZREMRANGEBYRANK" => :first,
292
+ "ZREMRANGEBYSCORE" => :first,
293
+ "ZREVRANGE" => :first,
294
+ "ZREVRANGEBYSCORE" => :first,
295
+ "ZREVRANK" => :first,
296
+ "ZSCORE" => :first,
297
+ "ZUNIONSTORE" => :zinterstore_style,
298
+ "ZMSCORE" => :first,
299
+ "ZSCAN" => :scan_style,
300
+ "ZDIFF" => :lmpop_style,
301
+ "ZDIFFSTORE" => :zinterstore_style,
302
+ "ZINTER" => :lmpop_style,
303
+ "ZUNION" => :lmpop_style,
304
+ "ZRANDMEMBER" => :first,
305
+ "BZMPOP" => :blmpop_style,
306
+ "ZMPOP" => :lmpop_style,
307
+ "ZINTERCARD" => :lmpop_style,
308
+ "ZRANGESTORE" => :first_two,
309
+
310
+ # Hash
311
+ "HDEL" => :first,
312
+ "HEXISTS" => :first,
313
+ "HGET" => :first,
314
+ "HGETALL" => :first,
315
+ "HINCRBY" => :first,
316
+ "HINCRBYFLOAT" => :first,
317
+ "HKEYS" => :first,
318
+ "HLEN" => :first,
319
+ "HMGET" => :first,
320
+ "HMSET" => :first,
321
+ "HSET" => :first,
322
+ "HSETNX" => :first,
323
+ "HSTRLEN" => :first,
324
+ "HVALS" => :first,
325
+ "HSCAN" => :scan_style,
326
+ "HRANDFIELD" => :first,
327
+ "HEXPIRE" => :first,
328
+ "HEXPIREAT" => :first,
329
+ "HEXPIRETIME" => :first,
330
+ "HPERSIST" => :first,
331
+ "HPEXPIRE" => :first,
332
+ "HPEXPIREAT" => :first,
333
+ "HPEXPIRETIME" => :first,
334
+ "HTTL" => :first,
335
+ "HPTTL" => :first,
336
+ "HGETF" => :first,
337
+ "HSETF" => :first,
338
+
339
+ # Hyperloglog
340
+ "PFADD" => :first,
341
+ "PFCOUNT" => :all,
342
+ "PFMERGE" => :all,
343
+ "PFDEBUG" => :second,
344
+
345
+ # Geo
346
+ "GEOADD" => :first,
347
+ "GEODIST" => :first,
348
+ "GEOHASH" => :first,
349
+ "GEOPOS" => :first,
350
+ "GEORADIUS" => :georadius_style,
351
+ "GEORADIUSBYMEMBER" => :georadius_style,
352
+ "GEOSEARCH" => :first,
353
+ "GEOSEARCHSTORE" => :first_two,
354
+ "GEORADIUS_RO" => :georadius_style,
355
+ "GEORADIUSBYMEMBER_RO" => :georadius_style,
356
+
357
+ # Stream
358
+ "XADD" => :first,
359
+ "XRANGE" => :first,
360
+ "XREVRANGE" => :first,
361
+ "XLEN" => :first,
362
+ "XREAD" => :xread_style,
363
+ "XREADGROUP" => :xread_style,
364
+ "XGROUP" => :second,
365
+ "XACK" => :first,
366
+ "XCLAIM" => :first,
367
+ "XDEL" => :first,
368
+ "XTRIM" => :first,
369
+ "XPENDING" => :first,
370
+ "XINFO" => :second,
371
+ "XAUTOCLAIM" => :first,
372
+ "XSETID" => :first,
373
+
374
+ # Pubsub
375
+ "PSUBSCRIBE" => :all,
376
+ "PUBLISH" => :first,
377
+ "PUNSUBSCRIBE" => :all,
378
+ "SUBSCRIBE" => :all,
379
+ "UNSUBSCRIBE" => :all,
380
+ "PUBSUB" => :pubsub_style,
381
+ "SPUBLISH" => :none,
382
+ "SSUBSCRIBE" => :none,
383
+ "SUNSUBSCRIBE" => :none,
384
+
385
+ # Transactions
386
+ "DISCARD" => :none,
387
+ "EXEC" => :none,
388
+ "MULTI" => :none,
389
+ "UNWATCH" => :none,
390
+ "WATCH" => :all,
391
+
392
+ # Scripting
393
+ "EVAL" => :eval_style,
394
+ "EVALSHA" => :eval_style,
395
+ "SCRIPT" => :none,
396
+ "EVAL_RO" => :eval_style,
397
+ "EVALSHA_RO" => :eval_style,
398
+ "FCALL" => :eval_style,
399
+ "FCALL_RO" => :eval_style,
400
+ "FUNCTION" => :none,
401
+
402
+ # Connection
403
+ "AUTH" => :none,
404
+ "ECHO" => :none,
405
+ "PING" => :none,
406
+ "QUIT" => :none,
407
+ "SELECT" => :none,
408
+ "SWAPDB" => :none,
409
+ "RESET" => :none,
410
+
411
+ # Server
412
+ "BGREWRITEAOF" => :none,
413
+ "BGSAVE" => :none,
414
+ "CLIENT" => :none,
415
+ "COMMAND" => :none,
416
+ "CONFIG" => :none,
417
+ "DBSIZE" => :none,
418
+ "DEBUG" => :none,
419
+ "FLUSHALL" => :none,
420
+ "FLUSHDB" => :none,
421
+ "INFO" => :none,
422
+ "LASTSAVE" => :none,
423
+ "MEMORY" => :memory_usage,
424
+ "MONITOR" => :none,
425
+ "SAVE" => :none,
426
+ "SHUTDOWN" => :none,
427
+ "SLAVEOF" => :none,
428
+ "SLOWLOG" => :none,
429
+ "SYNC" => :none,
430
+ "TIME" => :none,
431
+ "LATENCY" => :none,
432
+ "LOLWUT" => :none,
433
+ "ACL" => :none,
434
+ "MODULE" => :none,
435
+ "CLUSTER" => :none,
436
+ "HELLO" => :none,
437
+ "FAILOVER" => :none,
438
+ "REPLICAOF" => :none,
439
+ "PSYNC" => :none
440
+
441
+ }.freeze
442
+
443
+ def generate(args, kwargs = nil)
444
+ command = @parent_command_builder.generate(args, kwargs)
445
+ return command if @namespace.nil? || @namespace.empty? || command.size < 2
446
+
447
+ cmd_name = command[0].to_s.upcase
448
+ strategy = COMMANDS[cmd_name]
449
+
450
+ # Raise error for unknown commands to maintain compatibility with redis-namespace
451
+ unless strategy
452
+ raise(::RedisClient::Namespace::Error,
453
+ "RedisClient::Namespace does not know how to handle '#{cmd_name}'.")
454
+ end
455
+
456
+ STRATEGIES[strategy].call(command) { |key| rename_key(key) }
457
+
458
+ command
459
+ end
460
+
461
+ private
462
+
463
+ def rename_key(key)
464
+ return key if @namespace.nil? || @namespace.empty?
465
+
466
+ "#{@namespace}#{@separator}#{key}"
467
+ end
468
+ end
469
+ end
470
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisClient
4
+ class Namespace
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+ require_relative "namespace/version"
5
+ require_relative "namespace/command_builder"
6
+
7
+ class RedisClient
8
+ # RedisClient::Namespace provides transparent key namespacing for redis-client.
9
+ #
10
+ # It works by intercepting Redis commands and prefixing keys with a namespace,
11
+ # allowing multiple applications or components to share a single Redis instance
12
+ # without key collisions.
13
+ #
14
+ # @example Basic usage
15
+ # builder = RedisClient::Namespace.new("myapp")
16
+ # client = RedisClient.new(command_builder: builder)
17
+ # client.call("SET", "key", "value") # Actually sets "myapp:key"
18
+ #
19
+ # @example Custom separator
20
+ # builder = RedisClient::Namespace.new("myapp", separator: "-")
21
+ # client = RedisClient.new(command_builder: builder)
22
+ # client.call("SET", "key", "value") # Actually sets "myapp-key"
23
+ class Namespace
24
+ include RedisClient::Namespace::CommandBuilder
25
+
26
+ class Error < StandardError; end
27
+
28
+ attr_reader :namespace, :separator, :parent_command_builder
29
+
30
+ def initialize(namespace = "", separator: ":", parent_command_builder: RedisClient::CommandBuilder)
31
+ @namespace = namespace
32
+ @separator = separator
33
+ @parent_command_builder = parent_command_builder
34
+ end
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redis-client-namespace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kensaku Araga
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: redis-client
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.22.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.22.0
26
+ description: Adds transparent namespace prefixing to Redis keys for multi-tenant applications
27
+ using redis-client.
28
+ email:
29
+ - k_araga@ivry.jp
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".rspec"
35
+ - ".rubocop.yml"
36
+ - CHANGELOG.md
37
+ - CODE_OF_CONDUCT.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - compose.yml
42
+ - lib/redis-client-namespace.rb
43
+ - lib/redis_client/namespace.rb
44
+ - lib/redis_client/namespace/command_builder.rb
45
+ - lib/redis_client/namespace/version.rb
46
+ homepage: https://github.com/ken39arg/redis-client-namespace
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/ken39arg/redis-client-namespace
51
+ source_code_uri: https://github.com/ken39arg/redis-client-namespace
52
+ changelog_uri: https://github.com/ken39arg/redis-client-namespace/blob/main/CHANGELOG.md
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.1.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.9
69
+ specification_version: 4
70
+ summary: Namespace support for redis-client
71
+ test_files: []