bonkers-bitfields 1.0.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: 3c1c757381a66cc87dd65a60dc30fdcce2a4d937d368ab919bf3de33fe6d3629
4
+ data.tar.gz: 0be0ec980184561847d76fbfd1c5c8b00eb84638223fc055f18b22a1ad5b7057
5
+ SHA512:
6
+ metadata.gz: f8bb243863c98d585f5924e3aa58c56b66480404c72218f5dec46aedc8b9fae560f09a314390ee9069e8f4bfc23518de2ed177467842e64c0e86b327a152ffd2
7
+ data.tar.gz: eac346b8c6b45836024ce89002cdf4b60ca278f2f126f5f45525a98ddf7f1f943117c6cdd7ca62375d5b83b25b8c9cff0d8c24a922e76e2fba6332558194b3ca
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Changed (breaking)
10
+ - **Renamed the published gem to `bonkers-bitfields`.** The required library path
11
+ (`require "bitfields"`) and the `Bitfields` module are unchanged, so `include Bitfields`
12
+ and all generated methods keep working — only the `Gemfile` entry changes.
13
+ - **Positional bit declarations now warn by default.** `bitfield :flags, :a, :b, :c` maps each
14
+ symbol to `2**index`, so inserting, removing, or reordering a symbol silently shifts every later
15
+ bit. The new `Bitfields.positional_bits` setting controls this: `:warn` (default) emits a
16
+ warning, `:forbid` raises, `:allow` restores the old silent behaviour. Prefer the explicit
17
+ `bitfield :flags, 1 => :a, 2 => :b, 4 => :c` form, which locks each name to its bit.
18
+ - **Duplicate bit names across columns now raise `DuplicateBitNameError` at declaration time.**
19
+ Previously a name reused in two columns silently resolved to the first column. Bit names must be
20
+ unique per model.
21
+
22
+ ### Added
23
+ - `Bitfields.positional_bits` configuration (`:warn` / `:forbid` / `:allow`).
24
+ - `nil` / `:_skip` placeholders are allowed in positional declarations to reserve a bit position
25
+ without shifting later bits.
26
+ - `with_bitfields` / `without_bitfields` class query methods (resolves the long-standing README
27
+ TODO), built on Arel so they survive eager-load table aliasing (resolves
28
+ [#45](https://github.com/grosser/bitfields/issues/45)).
29
+ - GitHub Actions CI (`.github/workflows/ci.yml`) now lints and runs the test matrix on **every
30
+ branch** as well as pull requests, with concurrent superseded runs cancelled.
31
+ - Release workflow (`.github/workflows/release.yml`) that publishes the gem to RubyGems via
32
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC, no stored API key)
33
+ when a `v*` tag is pushed.
34
+
35
+ ### Fixed
36
+ - Cross-column duplicate bit names no longer silently misbehave
37
+ ([#21](https://github.com/grosser/bitfields/issues/21)).
38
+ - README SQL examples (`(users.my_bits & 6) = 2`; `users.my_bits` table reference).
39
+ - Duplicated assertion line in the RSpec `have_a_bitfield` matcher.
40
+
41
+ ### Removed
42
+ - Travis CI (replaced with GitHub Actions) and the `wwtd` development dependency.
43
+ - Support for Ruby < 3.1 and ActiveRecord < 6.1.
data/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # bonkers-bitfields
2
+
3
+ [![CI](https://github.com/bonkers-ie/bitfields/actions/workflows/ci.yml/badge.svg)](https://github.com/bonkers-ie/bitfields/actions/workflows/ci.yml)
4
+
5
+ Save migrations and columns by storing multiple booleans in a single integer.<br/>
6
+ e.g. true-false-false = 1, false-true-false = 2, true-false-true = 5 (1,2,4,8,..)
7
+
8
+ > This is a maintained fork of [grosser/bitfields](https://github.com/grosser/bitfields),
9
+ > published on RubyGems as **`bonkers-bitfields`**. The library is still required as
10
+ > `bitfields` and the module is still `Bitfields`, so existing code does not change.
11
+
12
+ ```ruby
13
+ class User < ActiveRecord::Base
14
+ include Bitfields
15
+ bitfield :flags, 1 => :vendor, 2 => :zany, 4 => :interesting
16
+ end
17
+
18
+ user = User.new(vendor: true, zany: true)
19
+ user.vendor # => true
20
+ user.interesting? # => false
21
+ user.flags # => 3
22
+ ```
23
+
24
+ ### Always declare explicit bits
25
+
26
+ `bitfield :flags, 1 => :vendor, 2 => :zany, 4 => :interesting` maps each name to an explicit bit,
27
+ so the mapping is locked even if you reorder the list. The positional shorthand
28
+ `bitfield :flags, :vendor, :zany, :interesting` instead maps each name to `2**index`, which means
29
+ **inserting, removing, or reordering a name silently shifts every later bit and corrupts stored
30
+ data**. By default a positional declaration now emits a warning; see
31
+ [`Bitfields.positional_bits`](#positional-bit-safety).
32
+
33
+ - records bitfield_changes `user.bitfield_changes # => {"vendor" => [false, true], "zany" => [false, true]}` (also `vendor_was` / `vendor_change` / `vendor_changed?` / `vendor_became_true?` / `vendor_became_false?`)
34
+ - Individual added methods (i.e, `vendor_was`, `vendor_changed?`, etc..) can be deactivated with `bitfield ..., added_instance_methods: false`
35
+ - **Note**: when used in the context of an `after_save` callback, `_was` returns the current value and `_changed?` returns `false`, since the previous changes have been persisted.
36
+ - convenient queries `User.with_bitfields(vendor: true, zany: false)` and `User.without_bitfields(vendor: true)`
37
+ - adds scopes `User.vendor.interesting.first` (deactivate with `bitfield ..., scopes: false`)
38
+ - builds sql `User.bitfield_sql(zany: true, interesting: false) # => '(users.flags & 6) = 2'`
39
+ - builds sql with OR condition `User.bitfield_sql({ zany: true, interesting: true }, query_mode: :bit_operator_or) # => '(users.flags & 2) = 2 OR (users.flags & 4) = 4'`
40
+ - builds index-using sql with `bitfield ... , query_mode: :in_list` and `User.bitfield_sql(zany: true, interesting: false) # => 'users.flags IN (2, 3)'` (2 and 1+2) often slower than :bit_operator sql especially for high number of bits
41
+ - builds update sql `User.set_bitfield_sql(zany: true, interesting: false) == 'flags = (flags | 6) - 4'`
42
+ - **faster sql than any other bitfield lib** through combination of multiple bits into a single sql statement
43
+ - gives access to bits `User.bitfields[:flags][:interesting] # => 4`
44
+ - converts hash to bits `User.bitfield_bits(vendor: true) # => 1`
45
+
46
+ Bit names must be unique per model: declaring the same name in two columns raises
47
+ `Bitfields::DuplicateBitNameError`.
48
+
49
+ Install
50
+ =======
51
+
52
+ ```bash
53
+ gem install bonkers-bitfields
54
+ ```
55
+
56
+ ```ruby
57
+ # Gemfile
58
+ gem "bonkers-bitfields"
59
+ ```
60
+
61
+ ```ruby
62
+ require "bitfields" # the library path and the Bitfields module are unchanged
63
+ ```
64
+
65
+ ### Migration
66
+ ALWAYS set a default, bitfield queries will not work for NULL
67
+
68
+ ```ruby
69
+ t.integer :flags, default: 0, null: false
70
+ # OR
71
+ add_column :users, :flags, :integer, default: 0, null: false
72
+ ```
73
+
74
+ Instance Methods
75
+ ================
76
+
77
+ ### Global Bitfield Methods
78
+ | Method Name | Example (`user = User.new(vendor: true, zany: true`) | Result |
79
+ |--------------------|---------------------------------------------------------|-------------------------------------------------------------|
80
+ | `bitfield_values` | `user.bitfield_values` | `{"vendor" => true, "zany" => true, "interesting" => false}` |
81
+ | `bitfield_changes` | `user.bitfield_changes` | `{"vendor" => [false, true], "zany" => [false, true]}` |
82
+
83
+ ### Individual Bit Methods
84
+ #### Model Getters / Setters
85
+ | Method Name | Example (`user = User.new`) | Result |
86
+ |----------------|-----------------------------|---------|
87
+ | `#{bit_name}` | `user.vendor` | `false` |
88
+ | `#{bit_name}=` | `user.vendor = true` | `true` |
89
+ | `#{bit_name}?` | `user.vendor = true; user.vendor?` | `true` |
90
+
91
+ #### Dirty Methods:
92
+
93
+ Some, not all, [`ActiveRecord::AttributeMethods::Dirty`](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html) and [`ActiveModel::Dirty`](https://api.rubyonrails.org/classes/ActiveModel/Dirty.html) methods can be used on each bitfield:
94
+
95
+ ##### Before Model Persistence
96
+ | Method Name | Example (`user = User.new`) | Result |
97
+ |------------------------------------|------------------------------------|-----------------|
98
+ | `#{bit_name}_was` | `user.vendor_was` | `false` |
99
+ | `#{bit_name}_in_database` | `user.vendor_in_database` | `false` |
100
+ | `#{bit_name}_change` | `user.vendor_change` | `[false, true]` |
101
+ | `#{bit_name}_change_to_be_saved` | `user.vendor_change_to_be_saved` | `[false, true]` |
102
+ | `#{bit_name}_changed?` | `user.vendor_changed?` | `true` |
103
+ | `will_save_change_to_#{bit_name}?` | `user.will_save_change_to_vendor?` | `true` |
104
+ | `#{bit_name}_became_true?` | `user.vendor_became_true?` | `true` |
105
+ | `#{bit_name}_became_false?` | `user.vendor_became_false?` | `false` |
106
+
107
+
108
+ ##### After Model Persistence
109
+ | Method Name | Example (`user = User.create(vendor: true)`) | Result |
110
+ |--------------------------------|---------------------------------------------------|-----------------|
111
+ | `#{bit_name}_before_last_save` | `user.vendor_before_last_save` | `false` |
112
+ | `saved_change_to_#{bit_name}` | `user.saved_change_to_vendor` | `[false, true]` |
113
+ | `saved_change_to_#{bit_name}?` | `user.saved_change_to_vendor?` | `true` |
114
+
115
+ - **Note**: These methods are dynamically defined for each bitfield, and function separately from the real `ActiveRecord::AttributeMethods::Dirty`/`ActiveModel::Dirty` methods. As such, generic methods (e.g. `attribute_before_last_save(:attribute)`) will not work.
116
+
117
+ Examples
118
+ ========
119
+ Update all users
120
+
121
+ ```ruby
122
+ User.vendor.not_interesting.update_all(User.set_bitfield_sql(vendor: true, zany: true))
123
+ ```
124
+
125
+ Delete the shop when a user is no longer a vendor
126
+
127
+ ```ruby
128
+ before_save :delete_shop, if: -> { |u| u.vendor_change == [true, false] }
129
+ ```
130
+
131
+ List fields and their respective values
132
+
133
+ ```ruby
134
+ user = User.new(zany: true)
135
+ user.bitfield_values(:flags) # => { vendor: false, zany: true, interesting: false }
136
+ ```
137
+
138
+ Querying through associations
139
+
140
+ ```ruby
141
+ # `with_bitfields` builds an Arel predicate, so it composes with eager loading. A raw string
142
+ # condition would silently match nothing here on modern ActiveRecord.
143
+ Team.includes(:members).references(:members).merge(User.with_bitfields(vendor: true))
144
+ ```
145
+
146
+ <a name="positional-bit-safety"></a>
147
+ Positional bit safety
148
+ =====================
149
+ Control how the positional shorthand (`bitfield :bits, :foo, :bar`) is treated:
150
+
151
+ ```ruby
152
+ Bitfields.positional_bits = :warn # default: warn that positional bits are fragile
153
+ Bitfields.positional_bits = :forbid # raise Bitfields::PositionalBitsError instead
154
+ Bitfields.positional_bits = :allow # legacy behaviour, no warning
155
+ ```
156
+
157
+ If you keep positional declarations, you can reserve a bit position with `nil` or `:_skip` so
158
+ removing a bit does not shift the bits after it:
159
+
160
+ ```ruby
161
+ bitfield :bits, :vendor, nil, :interesting # vendor => 1, interesting => 4 (bit 2 left unused)
162
+ ```
163
+
164
+ TIPS
165
+ ====
166
+ - [Defaults for new records] set via db migration or name the bit foo_off to avoid confusion, setting via after_initialize [does not work](https://github.com/grosser/bitfields/commit/2170dc546e2c4f1187089909a80e8602631d0796)
167
+ - It is slow to do: `#{bitfield_sql(...)} AND #{bitfield_sql(...)}`, merge both into one hash
168
+ - bit_operator is faster in most cases, use `query_mode: :in_list` sparingly
169
+ - Standard mysql integer is 4 byte -> 32 bitfields
170
+ - Prefer explicit bits `bitfield :bits, 1 => :foo, 2 => :bar, 4 => :baz` (or `2**0 => :foo, 2**1 => :bar`) over the positional shorthand `bitfield :bits, :foo, :bar, :baz`
171
+
172
+ Query-mode Benchmark
173
+ =========
174
+ The `query_mode: :in_list` is slower for most queries and scales miserably with the number of bits.<br/>
175
+ *Stay with the default query-mode*. Only use :in_list if your edge-case shows better performance.
176
+
177
+ Run the benchmark yourself with `ruby benchmark/bit_operator_vs_in.rb`: across 2–14 bits, `:bit_operator`
178
+ stays roughly flat while `:in_list` grows steeply with the number of bits.
179
+
180
+ Testing With RSpec
181
+ =========
182
+
183
+ To assert that a specific flag is a bitfield flag and has the `zany?`, `zany`, and `zany=` methods and behavior use the following matcher:
184
+
185
+ ````ruby
186
+ require 'bitfields/rspec'
187
+
188
+ describe User do
189
+ it { is_expected.to have_a_bitfield :zany }
190
+ end
191
+ ````
192
+
193
+ Supported versions
194
+ ===================
195
+ Ruby >= 3.1 and ActiveRecord 6.1 – 8.0, tested on CI.
196
+
197
+ Authors
198
+ =======
199
+ ### [Contributors](https://github.com/bonkers-ie/bitfields/contributors)
200
+ - [Ben Walsh](https://github.com/benwalsh)
201
+ - [Hellekin O. Wolf](https://github.com/hellekin)
202
+ - [John Wilkinson](https://github.com/jcwilk)
203
+ - [PeppyHeppy](https://github.com/peppyheppy)
204
+ - [kmcbride](https://github.com/kmcbride)
205
+ - [Justin Aiken](https://github.com/JustinAiken)
206
+ - [szTheory](https://github.com/szTheory)
207
+ - [Reed G. Law](https://github.com/reedlaw)
208
+ - [Rael Gugelmin Cunha](https://github.com/reedlaw)
209
+ - [Alan Wong](https://github.com/naganowl)
210
+ - [Andrew Bates](https://github.com/a-bates)
211
+ - [Shirish Pampoorickal](https://github.com/shirish-pampoorickal)
212
+ - [Sergey Kojin](https://github.com/skojin)
213
+
214
+ Originally by [Michael Grosser](http://grosser.it) (michael@grosser.it).<br/>
215
+ Maintained as `bonkers-bitfields` by [Bonkers.ie](https://github.com/bonkers-ie).<br/>
216
+ License: MIT
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec::Matchers.define :have_a_bitfield do |field|
4
+ match do |klass|
5
+ klass.respond_to?(field) &&
6
+ klass.respond_to?("#{field}?") &&
7
+ klass.respond_to?("#{field}=")
8
+ end
9
+
10
+ failure_message do |klass|
11
+ "expected #{expected.join} to be a bitfield property defined on #{klass}"
12
+ end
13
+
14
+ failure_message_when_negated do |klass|
15
+ "expected #{expected.join} to NOT be a bitfield property defined on #{klass}"
16
+ end
17
+
18
+ description do
19
+ "be a bitfield on #{expected}"
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfields
4
+ VERSION = '1.0.0'
5
+ Version = VERSION
6
+ end
data/lib/bitfields.rb ADDED
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bitfields/version'
4
+ require 'active_support'
5
+
6
+ module Bitfields
7
+ TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].freeze # taken from ActiveRecord::ConnectionAdapters::Column
8
+
9
+ # Symbols/values that reserve a positional bit without naming it, so removing a bit
10
+ # leaves a gap instead of silently shifting every later bit down.
11
+ POSITIONAL_PLACEHOLDERS = [nil, :_skip].freeze
12
+ POSITIONAL_MODES = %i[allow warn forbid].freeze
13
+
14
+ class DuplicateBitNameError < ArgumentError; end
15
+ class InvalidBitError < ArgumentError; end
16
+ class PositionalBitsError < ArgumentError; end
17
+
18
+ class << self
19
+ # How to treat the positional shorthand `bitfield :col, :a, :b` (maps each name to 2**index,
20
+ # so reordering/inserting/removing a name silently shifts stored bits):
21
+ # :warn (default) - emit a warning, :forbid - raise, :allow - stay silent (legacy behaviour)
22
+ attr_writer :positional_bits
23
+
24
+ def positional_bits
25
+ @positional_bits ||= :warn
26
+ end
27
+
28
+ def included(base)
29
+ class << base
30
+ attr_accessor :bitfields, :bitfield_options, :bitfield_args
31
+
32
+ # all the args passed into .bitfield so children can initialize from parents
33
+ def bitfield_args
34
+ @bitfield_args ||= []
35
+ end
36
+
37
+ def inherited(subclass)
38
+ super
39
+ subclass.bitfield_args = bitfield_args.dup
40
+ subclass.bitfield_args.each do |column, options|
41
+ subclass.send :store_bitfield_values, column, options.dup
42
+ end
43
+ end
44
+ end
45
+
46
+ base.extend Bitfields::ClassMethods
47
+ end
48
+
49
+ def extract_bits(options)
50
+ options.keys.grep(Numeric).each_with_object({}) do |bit, bitfields|
51
+ unless bit.is_a?(Integer) && bit.positive? && bit.nobits?(bit - 1)
52
+ raise InvalidBitError, "#{bit} is not a power of 2 !!"
53
+ end
54
+
55
+ bit_name = options.delete(bit).to_sym
56
+ raise DuplicateBitNameError if bitfields.key?(bit_name)
57
+
58
+ bitfields[bit_name] = bit
59
+ end
60
+ end
61
+ end
62
+
63
+ module ClassMethods
64
+ def bitfield(column, *args)
65
+ column = column.to_sym
66
+ options = extract_bitfield_options(column, args)
67
+ bitfield_args << [column, options.dup]
68
+
69
+ store_bitfield_values column, options
70
+ add_bitfield_methods column, options
71
+ end
72
+
73
+ def bitfield_bits(values)
74
+ bits = bitfields.values.reduce({}, :merge)
75
+ values.sum { |bit, on| on ? bits.fetch(bit) : 0 }
76
+ end
77
+
78
+ def bitfield_column(bit_name)
79
+ found = bitfields.find { |_, bits| bits.key?(bit_name.to_sym) }
80
+ raise "Unknown bitfield #{bit_name}" unless found
81
+
82
+ found.first
83
+ end
84
+
85
+ def bitfield_sql(bit_values, options = {})
86
+ bits = group_bits_by_column(bit_values).sort_by { |column, _| column.to_s }
87
+ bits.map { |column, values| bitfield_sql_by_column(column, values, options) }.join(' AND ')
88
+ end
89
+
90
+ # rubocop:disable Naming/AccessorMethodName -- public API name, kept for backwards compatibility
91
+ def set_bitfield_sql(bit_values)
92
+ bits = group_bits_by_column(bit_values).sort_by { |column, _| column.to_s }
93
+ bits.map { |column, values| set_bitfield_sql_by_column(column, values) }.join(', ')
94
+ end
95
+ # rubocop:enable Naming/AccessorMethodName
96
+
97
+ # Arel-based predicate for the given bits. Unlike the string SQL built by bitfield_sql,
98
+ # an Arel predicate carries its table relation, so it composes with eager loading
99
+ # (`includes`/`references`/`merge`) without the silent no-match that strings cause on
100
+ # ActiveRecord >= 6.1.
101
+ def bitfield_arel(bit_values)
102
+ predicates = group_bits_by_column(bit_values).sort_by { |column, _| column.to_s }.map do |column, values|
103
+ bitfield_arel_by_column(column, values)
104
+ end
105
+ predicates.reduce(:and)
106
+ end
107
+
108
+ # Convenient named query: `User.with_bitfields(vendor: true, zany: false)`
109
+ def with_bitfields(bit_values)
110
+ where(bitfield_arel(bit_values))
111
+ end
112
+
113
+ def without_bitfields(bit_values)
114
+ where.not(bitfield_arel(bit_values))
115
+ end
116
+
117
+ private
118
+
119
+ def extract_bitfield_options(column, args)
120
+ options = args.last.is_a?(Hash) ? args.pop.dup : {}
121
+ return options if args.empty?
122
+
123
+ enforce_positional_policy(column)
124
+ args.each_with_index do |field, index|
125
+ next if POSITIONAL_PLACEHOLDERS.include?(field)
126
+
127
+ options[2**index] = field # add fields given in normal args to options
128
+ end
129
+ options
130
+ end
131
+
132
+ def enforce_positional_policy(column)
133
+ case Bitfields.positional_bits
134
+ when :allow then nil
135
+ when :warn then warn(positional_bits_message(column))
136
+ when :forbid then raise PositionalBitsError, positional_bits_message(column)
137
+ else raise ArgumentError, "Bitfields.positional_bits must be one of #{POSITIONAL_MODES.inspect}"
138
+ end
139
+ end
140
+
141
+ def positional_bits_message(column)
142
+ "#{name}: bitfield #{column.inspect} uses positional bit names, which silently shift stored " \
143
+ 'bits if the list is reordered or an entry is removed. Declare explicit bits instead, ' \
144
+ "e.g. `bitfield #{column.inspect}, 1 => :first, 2 => :second`."
145
+ end
146
+
147
+ def store_bitfield_values(column, options)
148
+ self.bitfields ||= {}
149
+ self.bitfield_options ||= {}
150
+ extracted = Bitfields.extract_bits(options)
151
+ ensure_unique_bit_names!(column, extracted)
152
+ bitfields[column] = extracted
153
+ bitfield_options[column] = options
154
+ end
155
+
156
+ def ensure_unique_bit_names!(column, extracted)
157
+ taken = bitfields.except(column).values.flat_map(&:keys)
158
+ duplicate = extracted.keys.find { |bit_name| taken.include?(bit_name) }
159
+ return unless duplicate
160
+
161
+ raise DuplicateBitNameError,
162
+ "#{name}: bit name #{duplicate.inspect} is already defined on another bitfield column " \
163
+ '(bit names must be unique per model)'
164
+ end
165
+
166
+ def add_bitfield_methods(column, options)
167
+ bitfields[column].each_key do |bit_name|
168
+ if options[:added_instance_methods] != false
169
+ define_method(bit_name) { bitfield_value(bit_name) }
170
+ define_method("#{bit_name}?") { bitfield_value(bit_name) }
171
+ define_method("#{bit_name}=") { |value| set_bitfield_value(bit_name, value) }
172
+
173
+ # Dirty methods usable in before_save contexts
174
+ define_method("#{bit_name}_was") { bitfield_value_was(bit_name) }
175
+ alias_method "#{bit_name}_in_database", "#{bit_name}_was"
176
+
177
+ define_method("#{bit_name}_change") { bitfield_value_change(bit_name) }
178
+ alias_method "#{bit_name}_change_to_be_saved", "#{bit_name}_change"
179
+
180
+ define_method("#{bit_name}_changed?") { bitfield_value_change(bit_name).present? }
181
+ alias_method "will_save_change_to_#{bit_name}?", "#{bit_name}_changed?"
182
+
183
+ define_method("#{bit_name}_became_true?") do
184
+ value = bitfield_value(bit_name)
185
+ value && send("#{bit_name}_was") != value
186
+ end
187
+ define_method("#{bit_name}_became_false?") do
188
+ value = bitfield_value(bit_name)
189
+ !value && send("#{bit_name}_was") != value
190
+ end
191
+
192
+ # Dirty methods usable in after_save contexts
193
+ define_method("#{bit_name}_before_last_save") { bitfield_value_before_last_save(bit_name) }
194
+ define_method("saved_change_to_#{bit_name}") { saved_change_to_bitfield_value(bit_name) }
195
+ define_method("saved_change_to_#{bit_name}?") { saved_change_to_bitfield_value(bit_name).present? }
196
+ end
197
+
198
+ if options[:scopes] != false
199
+ scope bit_name, bitfield_scope_options(bit_name => true)
200
+ scope "not_#{bit_name}", bitfield_scope_options(bit_name => false)
201
+ end
202
+ end
203
+
204
+ include Bitfields::InstanceMethods
205
+ end
206
+
207
+ def bitfield_scope_options(bit_values)
208
+ -> { where(bitfield_arel(bit_values)) }
209
+ end
210
+
211
+ def bitfield_sql_by_column(column, bit_values, options = {})
212
+ mode = options[:query_mode] || bitfield_options[column][:query_mode] || :bit_operator
213
+ case mode
214
+ when :in_list
215
+ max = (bitfields[column].values.max * 2) - 1
216
+ bits = (0..max).to_a # all possible bits
217
+ bit_values.each do |bit_name, value|
218
+ bit = bitfields[column][bit_name]
219
+ # reject values with: bit off for true, bit on for false
220
+ bits.reject! { |i| i & bit == (value ? 0 : bit) }
221
+ end
222
+ "#{table_name}.#{column} IN (#{bits.join(',')})"
223
+ when :bit_operator
224
+ on, off = bit_values_to_on_off(column, bit_values)
225
+ "(#{table_name}.#{column} & #{on + off}) = #{on}"
226
+ when :bit_operator_or
227
+ on, off = bit_values_to_on_off(column, bit_values)
228
+ result = []
229
+ result << "(#{table_name}.#{column} & #{on}) <> 0" if on != 0
230
+ result << "(#{table_name}.#{column} & #{off}) <> #{off}" if off != 0
231
+ result.join(' OR ')
232
+ else raise("bitfields: unknown query mode #{mode.inspect}")
233
+ end
234
+ end
235
+
236
+ def set_bitfield_sql_by_column(column, bit_values)
237
+ on, off = bit_values_to_on_off(column, bit_values)
238
+ "#{column} = (#{column} | #{on + off}) - #{off}"
239
+ end
240
+
241
+ def bitfield_arel_by_column(column, bit_values)
242
+ attribute = arel_table[column]
243
+ on, off = bit_values_to_on_off(column, bit_values)
244
+ predicates = []
245
+ predicates << (attribute & on).eq(on) unless on.zero?
246
+ predicates << (attribute & off).eq(0) unless off.zero?
247
+ predicates.reduce(:and)
248
+ end
249
+
250
+ def group_bits_by_column(bit_values)
251
+ columns = {}
252
+ bit_values.each do |bit_name, value|
253
+ column = bitfield_column(bit_name.to_sym)
254
+ columns[column] ||= {}
255
+ columns[column][bit_name.to_sym] = value
256
+ end
257
+ columns
258
+ end
259
+
260
+ def bit_values_to_on_off(column, bit_values)
261
+ on = off = 0
262
+ bit_values.each do |bit_name, value|
263
+ bit = bitfields[column][bit_name]
264
+ value ? on += bit : off += bit
265
+ end
266
+ [on, off]
267
+ end
268
+ end
269
+
270
+ module InstanceMethods
271
+ def bitfield_values(column)
272
+ self.class.bitfields[column.to_sym].keys.to_h { |bit_name| [bit_name, bitfield_value(bit_name)] }
273
+ end
274
+
275
+ def bitfield_changes
276
+ self.class.bitfields.values.flat_map(&:keys).each_with_object({}) do |bit, changes|
277
+ old = bitfield_value_was(bit)
278
+ current = bitfield_value(bit)
279
+ changes[bit.to_s] = [old, current] unless old == current
280
+ end
281
+ end
282
+
283
+ private
284
+
285
+ def bitfield_value(bit_name)
286
+ _, bit, current_value = bitfield_info(bit_name)
287
+ current_value & bit != 0
288
+ end
289
+
290
+ def bitfield_value_was(bit_name)
291
+ column, bit, = bitfield_info(bit_name)
292
+ send("#{column}_was") & bit != 0
293
+ end
294
+
295
+ def bitfield_value_before_last_save(bit_name)
296
+ column, bit, = bitfield_info(bit_name)
297
+ column_before_last_save = send("#{column}_before_last_save")
298
+ column_before_last_save.nil? ? nil : column_before_last_save & bit != 0
299
+ end
300
+
301
+ def bitfield_value_change(bit_name)
302
+ values = [bitfield_value_was(bit_name), bitfield_value(bit_name)]
303
+ values unless values[0] == values[1]
304
+ end
305
+
306
+ def saved_change_to_bitfield_value(bit_name)
307
+ value_before_last_save = bitfield_value_before_last_save(bit_name)
308
+ current_value = bitfield_value(bit_name)
309
+ return if value_before_last_save.nil? || (value_before_last_save == current_value)
310
+
311
+ [value_before_last_save, current_value]
312
+ end
313
+
314
+ def set_bitfield_value(bit_name, value)
315
+ column, bit, current_value = bitfield_info(bit_name)
316
+ new_value = TRUE_VALUES.include?(value)
317
+ old_value = bitfield_value(bit_name)
318
+ return if new_value == old_value
319
+
320
+ # 8 + 1 == 9 // 8 + 8 == 8 // 1 - 8 == 1 // 8 - 8 == 0
321
+ new_bits = new_value ? current_value | bit : (current_value | bit) - bit
322
+ send("#{column}=", new_bits)
323
+ end
324
+
325
+ def bitfield_info(bit_name)
326
+ column = self.class.bitfield_column(bit_name)
327
+ [
328
+ column,
329
+ self.class.bitfields[column][bit_name], # bit
330
+ send(column) || 0 # current value
331
+ ]
332
+ end
333
+ end
334
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bonkers-bitfields
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael Grosser
8
+ - Bonkers.ie
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '6.1'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: bump
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rubocop
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rubocop-performance
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rubocop-rspec
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: sqlite3
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ email: michael@grosser.it
132
+ executables: []
133
+ extensions: []
134
+ extra_rdoc_files: []
135
+ files:
136
+ - CHANGELOG.md
137
+ - README.md
138
+ - lib/bitfields.rb
139
+ - lib/bitfields/rspec.rb
140
+ - lib/bitfields/version.rb
141
+ homepage: https://github.com/bonkers-ie/bitfields
142
+ licenses:
143
+ - MIT
144
+ metadata:
145
+ source_code_uri: https://github.com/bonkers-ie/bitfields
146
+ changelog_uri: https://github.com/bonkers-ie/bitfields/blob/main/CHANGELOG.md
147
+ bug_tracker_uri: https://github.com/bonkers-ie/bitfields/issues
148
+ rubygems_mfa_required: 'true'
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '3.1'
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 3.6.9
164
+ specification_version: 4
165
+ summary: Save migrations and columns by storing multiple booleans in a single integer
166
+ test_files: []