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 +7 -0
- data/CHANGELOG.md +43 -0
- data/README.md +216 -0
- data/lib/bitfields/rspec.rb +21 -0
- data/lib/bitfields/version.rb +6 -0
- data/lib/bitfields.rb +334 -0
- metadata +166 -0
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
|
+
[](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
|
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: []
|