bitwise_attributes 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d64d7b8f1ab83f3c86a41ae0a2c868f5d5f49599172e597e789484d7bb36834
4
- data.tar.gz: c57864b2eb6228a95cc7ef390eb1054851682a2af0b3921eaf4cfaa0946b7d59
3
+ metadata.gz: 838a31f15821813cb22ce30c7830c6b0d7d77f106346a0902fe7e2dc97d40ed0
4
+ data.tar.gz: 1e2c6222ab5c571dbccdde150eba4587b60c70da76ebecc4dd111e0e2457224d
5
5
  SHA512:
6
- metadata.gz: 411acc6c6b0a0041191c603d213fe563d1c444e3921df4890c21016acc571b1c044ea6a898105f5eca0200551fdb2b3cc6fa7dcaf94a875e2a955a5dfdbf963a
7
- data.tar.gz: 715f9fba193a68687e85a0bdf1f52df8f6260972ed9bb6353298c75ff5a24011e0f87a100b3addc262775e264b6ef6521273569982fd4c40a9b6c6a1464df92b
6
+ metadata.gz: d360c4318ebca0adc53dd75a9081670f0362171a98385c79e5eaf221056d7074d75009a11022aaea1e5a1bf0e6ed7f4cfc962ef4db0bdeabcce672c4b5cf01a9
7
+ data.tar.gz: d3410f2652d74877c51b97c12cffeaff314de1abcba061f0d0c9644f88953bc6328e045b84a271057d8c5c37dfc527c4fd0e7b2871819b9e2dd7d5f57d5e3f0b
data/.rubocop.yml CHANGED
@@ -1,5 +1,7 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 3.1
3
+ NewCops: disable
4
+ SuggestExtensions: false
3
5
 
4
6
  Style/StringLiterals:
5
7
  Enabled: true
@@ -11,3 +13,33 @@ Style/StringLiteralsInInterpolation:
11
13
 
12
14
  Layout/LineLength:
13
15
  Max: 120
16
+
17
+ Style/Documentation:
18
+ Exclude:
19
+ - "lib/bitwise_attributes/active_record_extension.rb"
20
+ - "spec/**/*"
21
+
22
+ Metrics/ModuleLength:
23
+ Exclude:
24
+ - "lib/bitwise_attributes/active_record_extension.rb"
25
+
26
+ Metrics/BlockLength:
27
+ Exclude:
28
+ - "spec/**/*"
29
+ - "lib/bitwise_attributes/active_record_extension.rb"
30
+
31
+ Metrics/MethodLength:
32
+ Exclude:
33
+ - "lib/bitwise_attributes/active_record_extension.rb"
34
+
35
+ Metrics/AbcSize:
36
+ Exclude:
37
+ - "lib/bitwise_attributes/active_record_extension.rb"
38
+
39
+ Metrics/CyclomaticComplexity:
40
+ Exclude:
41
+ - "lib/bitwise_attributes/active_record_extension.rb"
42
+
43
+ Metrics/PerceivedComplexity:
44
+ Exclude:
45
+ - "lib/bitwise_attributes/active_record_extension.rb"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-07-05
4
+
5
+ ### Added
6
+ - `toggle_key_bit` instance method (XOR) for each defined key
7
+ - `with_all_attr(keys)` scope — "all of these bits are set" (any additional bits are also OK)
8
+ - `with_exactly_attr(keys)` scope — true exact equality (`WHERE column = bitmask`)
9
+ - Array-based setter: `record.attr = [:key_a, :key_b]` computes and assigns the bitmask; `nil` maps to `0`; raw integers pass through unchanged
10
+ - `validates_bitwise_attribute(attr, **options)` class macro — adds a numericality validation ensuring the stored integer is between `0` and the maximum possible bitmask for that attribute
11
+ - Overflow guard at definition time: raises `ArgumentError` when key count exceeds 62; warns to stderr when it exceeds 30 (recommend BIGINT column)
12
+ - Validates that at least one key is provided and that no duplicate keys are present when calling `bitwise_attribute`
13
+
14
+ ### Changed
15
+ - `with_exact_attr` renamed to `with_all_attr` — the old name implied exact column equality but the SQL performs a "has all" check; `with_exactly_attr` is the new true-exact scope
16
+ - Column names are now quoted via `connection.quote_column_name` in all three SQL scopes for cross-adapter correctness
17
+ - Scope lambdas capture the model class at definition time instead of using `ActiveRecord::Relation#model` at query time
18
+ - Error messages from invalid key lookups now report only the unrecognised keys, not the full input array
19
+ - `normalize_and_fetch_values` is now a public class method (no longer bypassed with `send` from instance context)
20
+ - `update_bitwise_attribute` is now a private instance method
21
+
22
+ ### Fixed
23
+ - Nil safety: all bitwise operations now call `.to_i` on the raw attribute value, preventing `NoMethodError` when the column is `NULL` or the record is unsaved
24
+
3
25
  ## [0.1.0] - 2026-07-05
4
26
 
5
27
  ### Added
data/CLAUDE.md CHANGED
@@ -52,12 +52,16 @@ A single integer column stores multiple boolean flags packed as bits. Each flag
52
52
  |---|---|
53
53
  | `key_bit?` | Check if a specific bit is set |
54
54
  | `set_key_bit` / `unset_key_bit` | Set or clear a single bit |
55
+ | `toggle_key_bit` | XOR-flip a single bit |
55
56
  | `attr_name_key` / `attr_name_key=` | Boolean getter/setter (accepts truthy values) |
56
57
  | `was_previously_key_bit?` | Dirty-tracking: was bit set before last save |
58
+ | `attr_name=` | Assign full flag set from an `Array`, `Integer`, or `nil` |
57
59
  | `associated_attr_name` | Returns array of all currently-set key names |
58
60
  | `set_attr_name(*keys)` / `unset_attr_name(*keys)` | Bulk OR-in / AND-NOT-out |
61
+ | `validates_bitwise_attribute(attr, **opts)` | Class macro: numericality validation (0..max_bitmask) |
59
62
  | `with_attr_name(keys)` | Scope: any of the given bits set |
60
- | `with_exact_attr_name(keys)` | Scope: exactly these bits set |
63
+ | `with_all_attr_name(keys)` | Scope: all of the given bits set (other bits may also be set) |
64
+ | `with_exactly_attr_name(keys)` | Scope: column equals the bitmask exactly |
61
65
  | `without_attr_name(keys)` | Scope: none of the given bits set |
62
66
 
63
67
  ### Aliases
@@ -76,4 +80,4 @@ Aliases are resolved transparently in scopes and bulk set/unset operations.
76
80
 
77
81
  ## RuboCop
78
82
 
79
- Configured in `.rubocop.yml`: Ruby 2.6 target, double-quoted strings enforced, max line length 120.
83
+ Configured in `.rubocop.yml`: Ruby 3.1 target, double-quoted strings enforced, max line length 120.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- bitwise_attributes (0.1.0)
4
+ bitwise_attributes (0.2.0)
5
5
  activerecord (>= 6.1.7.3, < 9)
6
6
  activesupport (>= 6.1.7.3, < 9)
7
7
 
data/README.md CHANGED
@@ -51,6 +51,7 @@ For each key the following methods are generated (shown for `read`):
51
51
  user.read_bit? # => true / false
52
52
  user.set_read_bit # sets the bit in memory
53
53
  user.unset_read_bit # clears the bit in memory
54
+ user.toggle_read_bit # XOR — flips the bit
54
55
 
55
56
  user.permissions_read # alias for read_bit?
56
57
  user.permissions_read = true # accepts any truthy/falsy value
@@ -65,19 +66,35 @@ user.was_previously_read_bit? # dirty-tracking: state before last save
65
66
  user.set_permissions(:read, :admin) # OR-in multiple bits
66
67
  user.unset_permissions(:write) # AND-NOT-out multiple bits
67
68
 
69
+ user.permissions = [:read, :admin] # assign the full set from an array
70
+ user.permissions = 5 # raw integer passthrough also accepted
71
+
68
72
  user.associated_permissions # => ["read", "admin"]
69
73
  user.permissions_values # => {"read"=>1, "write"=>2, "admin"=>4}
70
74
  ```
71
75
 
76
+ ### Validation
77
+
78
+ ```ruby
79
+ class User < ApplicationRecord
80
+ include BitwiseAttributes
81
+ bitwise_attribute :permissions, :read, :write, :admin
82
+ validates_bitwise_attribute :permissions # 0..7, integer only
83
+ validates_bitwise_attribute :flags, allow_nil: true # passes Rails validator options through
84
+ end
85
+ ```
86
+
72
87
  ### Scopes
73
88
 
74
89
  ```ruby
75
- User.with_permissions(:read) # any of the given bits set
90
+ User.with_permissions(:read) # any of the given bits set
76
91
  User.with_permissions([:read, :write])
77
92
 
78
- User.with_exact_permissions([:read, :write]) # ALL given bits set (superset)
93
+ User.with_all_permissions([:read, :write]) # ALL given bits set (other bits may also be set)
94
+
95
+ User.with_exactly_permissions([:read, :write]) # column = bitmask exactly (no other bits)
79
96
 
80
- User.without_permissions(:admin) # none of the given bits set
97
+ User.without_permissions(:admin) # none of the given bits set
81
98
  ```
82
99
 
83
100
  ### Aliases
@@ -9,7 +9,9 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["mehboob.ali@7vals.com"]
10
10
 
11
11
  spec.summary = "Store multiple boolean flags in a single integer column using bitwise operations."
12
- spec.description = "BitwiseAttributes extends ActiveRecord models with a bitwise_attribute DSL that packs multiple boolean flags into a single integer column, generating per-flag getter/setter methods and query scopes automatically."
12
+ spec.description = "BitwiseAttributes extends ActiveRecord models with a bitwise_attribute DSL that packs " \
13
+ "multiple boolean flags into a single integer column, generating per-flag getter/setter " \
14
+ "methods and query scopes automatically."
13
15
  spec.homepage = "https://github.com/mehboobali98/bitwise_attributes"
14
16
  spec.license = "MIT"
15
17
  spec.required_ruby_version = ">= 3.1.4"
@@ -7,31 +7,39 @@ module BitwiseAttributes
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  class_methods do
10
- # Define a bitwise attribute and dynamically generate methods and scopes
11
10
  def bitwise_attribute(attribute_name, *keys, aliases: {})
11
+ # C-03: reject empty or duplicate key lists up front
12
+ raise ArgumentError, "#{attribute_name}: at least one key is required" if keys.empty?
13
+
14
+ dupes = keys.tally.select { |_, n| n > 1 }.keys
15
+ raise ArgumentError, "#{attribute_name}: duplicate keys #{dupes.inspect}" if dupes.any?
16
+
17
+ # C-02: overflow guard — 32-bit INT holds 30 usable bits; BIGINT holds 62
18
+ if keys.size > 62
19
+ raise ArgumentError, "#{attribute_name}: #{keys.size} keys exceeds the 62-bit BIGINT limit"
20
+ elsif keys.size > 30
21
+ warn "[BitwiseAttributes] #{attribute_name} has #{keys.size} keys — use a BIGINT column to avoid overflow"
22
+ end
23
+
12
24
  invalid_aliases = aliases.values - keys
13
25
  raise ArgumentError, "Invalid aliases for #{attribute_name}: #{invalid_aliases}" if invalid_aliases.any?
14
26
 
15
- bitwise_aliases[attribute_name] = aliases.with_indifferent_access.freeze
16
- bitwise_attributes[attribute_name] = keys.map.with_index { |key, index| [key, 1 << index] }.to_h.with_indifferent_access.freeze
27
+ bitwise_aliases[attribute_name] = aliases.with_indifferent_access.freeze
28
+ bitwise_attributes[attribute_name] = keys.map.with_index { |key, index| [key, 1 << index] }
29
+ .to_h.with_indifferent_access.freeze
17
30
  define_bitwise_methods(attribute_name, keys)
18
31
  end
19
32
 
20
- # Retrieve all defined bitwise aliases
21
33
  def bitwise_aliases
22
34
  @bitwise_aliases ||= Hash.new { |h, k| h[k] = {} }
23
35
  end
24
36
 
25
- # Retrieve all defined bitwise attributes
26
37
  def bitwise_attributes
27
38
  @bitwise_attributes ||= {}
28
39
  end
29
40
 
30
- # Ensure inheritance propagates bitwise attributes to subclasses
31
41
  def inherited(subclass)
32
42
  super
33
-
34
- # Copy bitwise attributes to the subclass
35
43
  subclass.instance_variable_set(:@bitwise_aliases, bitwise_aliases.dup)
36
44
  subclass.instance_variable_set(:@bitwise_attributes, bitwise_attributes.dup)
37
45
  end
@@ -48,108 +56,138 @@ module BitwiseAttributes
48
56
  end
49
57
  end
50
58
 
59
+ # G-01: validation macro — ensures the stored integer cannot exceed the declared key space
60
+ def validates_bitwise_attribute(attribute_name, **options)
61
+ max_value = bitwise_attributes.fetch(attribute_name).values.sum
62
+ validates attribute_name,
63
+ numericality: {
64
+ only_integer: true,
65
+ greater_than_or_equal_to: 0,
66
+ less_than_or_equal_to: max_value
67
+ },
68
+ **options
69
+ end
70
+
71
+ # G-03: public so instance methods can call it without send
72
+ def normalize_and_fetch_values(attribute_name, keys)
73
+ alias_mappings = bitwise_aliases[attribute_name]
74
+ attribute_values = bitwise_attributes[attribute_name]
75
+ Array(keys).uniq.map { |key| attribute_values[alias_mappings.fetch(key, key)] }
76
+ end
77
+
51
78
  private
52
79
 
53
- # Dynamically define methods and scopes for a bitwise attribute
54
80
  def define_bitwise_methods(attribute_name, keys)
55
- # Define a method to return the bitwise mapping for this attribute
56
- define_method(:"#{attribute_name}_values") do
57
- self.class.bitwise_attributes[attribute_name]
58
- end
81
+ model_class = self # Q-03: capture at definition time; avoids using AR::Relation#model inside lambdas
59
82
 
60
- define_method(:"#{attribute_name}_aliases") do
61
- self.class.bitwise_aliases[attribute_name]
62
- end
83
+ define_method(:"#{attribute_name}_values") { self.class.bitwise_attributes[attribute_name] }
84
+ define_method(:"#{attribute_name}_aliases") { self.class.bitwise_aliases[attribute_name] }
63
85
 
64
- # Define methods for individual keys
65
86
  keys.each do |key|
66
87
  define_method(:"#{key}_bit?") do
67
88
  bit_value = send(:"#{attribute_name}_values")[key]
68
- (self[attribute_name] & bit_value) != 0
89
+ (self[attribute_name].to_i & bit_value) != 0 # C-01: .to_i guards against NULL
69
90
  end
70
91
 
71
92
  define_method(:"set_#{key}_bit") do
72
93
  bit_value = send(:"#{attribute_name}_values")[key]
73
- self[attribute_name] |= bit_value
94
+ self[attribute_name] = self[attribute_name].to_i | bit_value # C-01
74
95
  end
75
96
 
76
97
  define_method(:"unset_#{key}_bit") do
77
98
  bit_value = send(:"#{attribute_name}_values")[key]
78
- self[attribute_name] &= ~bit_value
99
+ self[attribute_name] = self[attribute_name].to_i & ~bit_value # C-01
100
+ end
101
+
102
+ # A-02: XOR toggle — the most natural bitwise operation
103
+ define_method(:"toggle_#{key}_bit") do
104
+ bit_value = send(:"#{attribute_name}_values")[key]
105
+ self[attribute_name] = self[attribute_name].to_i ^ bit_value
79
106
  end
80
107
 
81
108
  define_method(:"#{attribute_name}_#{key}=") do |val|
82
109
  send(ActiveModel::Type::Boolean.new.cast(val) ? :"set_#{key}_bit" : :"unset_#{key}_bit")
83
110
  end
84
111
 
85
- define_method(:"#{attribute_name}_#{key}") do
86
- send(:"#{key}_bit?")
87
- end
112
+ define_method(:"#{attribute_name}_#{key}") { send(:"#{key}_bit?") }
88
113
 
89
114
  define_method(:"was_previously_#{key}_bit?") do
90
115
  bit_value = send(:"#{attribute_name}_values")[key]
91
- (attribute_previously_was(attribute_name) & bit_value) != 0
116
+ (attribute_previously_was(attribute_name).to_i & bit_value) != 0 # C-01
92
117
  end
93
118
  end
94
119
 
95
- # Define helper methods for bulk operations
96
- define_method(:"set_#{attribute_name}") do |*keys_to_add|
97
- update_bitwise_attribute(attribute_name, keys_to_add, :add)
98
- end
120
+ define_method(:"set_#{attribute_name}") { |*ks| update_bitwise_attribute(attribute_name, ks, :add) }
121
+ define_method(:"unset_#{attribute_name}") { |*ks| update_bitwise_attribute(attribute_name, ks, :remove) }
99
122
 
100
- define_method(:"unset_#{attribute_name}") do |*keys_to_remove|
101
- update_bitwise_attribute(attribute_name, keys_to_remove, :remove)
123
+ define_method(:"associated_#{attribute_name}") do
124
+ send(:"#{attribute_name}_values").reject { |_key, bit| (self[attribute_name].to_i & bit).zero? }.keys # C-01
102
125
  end
103
126
 
104
- # Define method to retrieve associated keys
105
- define_method(:"associated_#{attribute_name}") do
106
- send(:"#{attribute_name}_values").reject { |_key, bit| (self[attribute_name] & bit).zero? }.keys
127
+ # A-04: assign the full flag set from an array or pass an integer through directly
128
+ define_method(:"#{attribute_name}=") do |value|
129
+ self[attribute_name] =
130
+ case value
131
+ when Integer then value
132
+ when Array then self.class.send(:calculate_bitmask, attribute_name, value.map(&:to_s))
133
+ when NilClass then 0
134
+ else raise ArgumentError, "Expected Integer or Array for #{attribute_name}, got #{value.class}"
135
+ end
107
136
  end
108
137
 
109
- # Define dynamic scopes for filtering
138
+ # Q-01: quote column name via the adapter; Q-03: use captured model_class, not AR::Relation#model
110
139
  scope :"with_#{attribute_name}", lambda { |bit_keys|
111
- bitmask = model.send(:calculate_bitmask, attribute_name, bit_keys)
112
- where("#{attribute_name} & ? != 0", bitmask)
140
+ col = model_class.connection.quote_column_name(attribute_name)
141
+ bitmask = model_class.send(:calculate_bitmask, attribute_name, bit_keys)
142
+ where("#{col} & ? != 0", bitmask)
113
143
  }
114
144
 
115
- scope :"with_exact_#{attribute_name}", lambda { |bit_keys|
116
- bitmask = model.send(:calculate_bitmask, attribute_name, bit_keys)
117
- where("#{attribute_name} & :bitmask = :bitmask", { bitmask: bitmask })
145
+ # A-01: renamed from with_exact_ — semantics are "has ALL of these bits" (other bits may also be set)
146
+ scope :"with_all_#{attribute_name}", lambda { |bit_keys|
147
+ col = model_class.connection.quote_column_name(attribute_name)
148
+ bitmask = model_class.send(:calculate_bitmask, attribute_name, bit_keys)
149
+ where("#{col} & :bitmask = :bitmask", { bitmask: bitmask })
150
+ }
151
+
152
+ # A-01: true exact equality — column value must equal the bitmask precisely
153
+ scope :"with_exactly_#{attribute_name}", lambda { |bit_keys|
154
+ bitmask = model_class.send(:calculate_bitmask, attribute_name, bit_keys)
155
+ where(attribute_name => bitmask)
118
156
  }
119
157
 
120
158
  scope :"without_#{attribute_name}", lambda { |bit_keys|
121
- bitmask = model.send(:calculate_bitmask, attribute_name, bit_keys)
122
- where("#{attribute_name} & ? = 0", bitmask)
159
+ col = model_class.connection.quote_column_name(attribute_name)
160
+ bitmask = model_class.send(:calculate_bitmask, attribute_name, bit_keys)
161
+ where("#{col} & ? = 0", bitmask)
123
162
  }
124
163
  end
125
164
 
126
- # Calculate the bitmask for given keys (now supports aliases)
127
165
  def calculate_bitmask(attribute_name, keys)
128
- valid_values = normalize_and_fetch_values(attribute_name, keys)
129
- raise ArgumentError, "Invalid #{attribute_name}: #{keys}" if valid_values.include?(nil)
166
+ # Q-02: zip so we can report only the unrecognised keys, not the whole input
167
+ unique_keys = Array(keys).uniq
168
+ values = normalize_and_fetch_values(attribute_name, unique_keys)
169
+ invalid = unique_keys.zip(values).filter_map { |k, v| k if v.nil? }
170
+ raise ArgumentError, "Unknown #{attribute_name} keys: #{invalid.inspect}" if invalid.any?
130
171
 
131
- valid_values.sum
172
+ values.sum
132
173
  end
174
+ end
133
175
 
134
- def normalize_and_fetch_values(attribute_name, keys)
135
- alias_mappings = bitwise_aliases[attribute_name]
136
- attribute_values = bitwise_attributes[attribute_name]
137
-
138
- Array(keys).uniq.map { |key| attribute_values[alias_mappings.fetch(key, key)] }
139
- end
176
+ # G-03: direct call — no send needed now that normalize_and_fetch_values is public on the class
177
+ def normalize_and_fetch_values(attribute_name, keys)
178
+ self.class.normalize_and_fetch_values(attribute_name, keys)
140
179
  end
141
180
 
142
- # Update bitwise attributes for bulk add/remove operations
143
181
  def update_bitwise_attribute(attribute_name, keys, operation)
144
- valid_values = send(:normalize_and_fetch_values, attribute_name, keys)
145
- raise ArgumentError, "Invalid #{attribute_name}: #{keys}" if valid_values.include?(nil)
146
-
147
- bitmask = valid_values.sum
148
- self[attribute_name] = operation == :add ? (self[attribute_name] | bitmask) : (self[attribute_name] & ~bitmask)
182
+ bitmask = self.class.send(:calculate_bitmask, attribute_name, keys)
183
+ self[attribute_name] =
184
+ if operation == :add
185
+ self[attribute_name].to_i | bitmask # C-01
186
+ else
187
+ self[attribute_name].to_i & ~bitmask # C-01
188
+ end
149
189
  end
150
190
 
151
- def normalize_and_fetch_values(attribute_name, keys)
152
- self.class.send(:normalize_and_fetch_values, attribute_name, keys)
153
- end
191
+ private :update_bitwise_attribute # A-03: internal helper — not part of the public instance API
154
192
  end
155
193
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BitwiseAttributes
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitwise_attributes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehboob Ali