activerecord-bitwise 0.10.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
- checksums.yaml.gz.sig +2 -0
- data/BUGS.md +67 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +347 -0
- data/REQUIREMENTS.md +16 -0
- data/certs/activerecord-bitwise-public_cert.pem +25 -0
- data/lib/active_record/bitwise/bitwise_validator.rb +26 -0
- data/lib/active_record/bitwise/version.rb +9 -0
- data/lib/active_record/bitwise.rb +711 -0
- data/lib/activerecord-bitwise.rb +3 -0
- data/lib/tapioca/dsl/compilers/activerecord_bitwise.rb +207 -0
- data.tar.gz.sig +0 -0
- metadata +278 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require 'active_record/bitwise/version'
|
|
6
|
+
require 'active_record/bitwise/bitwise_validator'
|
|
7
|
+
require 'active_record'
|
|
8
|
+
|
|
9
|
+
module ActiveRecord
|
|
10
|
+
# The main namespace for the ActiveRecord::Bitwise gem, which provides capabilities
|
|
11
|
+
# to store multiple boolean or enum values in a single integer column.
|
|
12
|
+
module Bitwise
|
|
13
|
+
# Base error class for all ActiveRecord::Bitwise gem exceptions.
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Exception raised when an unsupported query or operation is attempted.
|
|
17
|
+
class NotSupportedError < Error; end
|
|
18
|
+
|
|
19
|
+
# rubocop:disable Metrics/ClassLength
|
|
20
|
+
# Justification: The custom Type class houses all custom serialization, deserialization,
|
|
21
|
+
# and type casting logic for ActiveModel integration, which are highly cohesive
|
|
22
|
+
# and best kept encapsulated in a single type handler.
|
|
23
|
+
class Type < ActiveRecord::Type::Value
|
|
24
|
+
extend T::Sig
|
|
25
|
+
|
|
26
|
+
# The column name associated with this type.
|
|
27
|
+
# @return [Symbol]
|
|
28
|
+
sig { returns(Symbol) }
|
|
29
|
+
attr_reader :column_name
|
|
30
|
+
|
|
31
|
+
# The mapping of symbol keys to bit position integers.
|
|
32
|
+
# @return [T::Hash[Symbol, Integer]]
|
|
33
|
+
sig { returns(T::Hash[Symbol, Integer]) }
|
|
34
|
+
attr_reader :mapping
|
|
35
|
+
|
|
36
|
+
# The default array of values.
|
|
37
|
+
# @return [T::Array[T.any(Symbol, String)]]
|
|
38
|
+
sig { returns(T::Array[T.any(Symbol, String)]) }
|
|
39
|
+
attr_reader :default
|
|
40
|
+
|
|
41
|
+
# The bitmask representing all known mapped positions.
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
sig { returns(Integer) }
|
|
44
|
+
attr_reader :known_mask
|
|
45
|
+
|
|
46
|
+
# The list of mapping keys converted to strings for Symbol DoS prevention.
|
|
47
|
+
# @return [T::Array[String]]
|
|
48
|
+
sig { returns(T::Array[String]) }
|
|
49
|
+
attr_reader :mapping_strings
|
|
50
|
+
|
|
51
|
+
# Initializes the bitwise type cast configuration.
|
|
52
|
+
# @param column_name [T.any(Symbol, String)] The column name.
|
|
53
|
+
# @param mapping [T::Hash[Symbol, Integer]] The mapping of keys to bit positions.
|
|
54
|
+
# @param default [T.nilable(T::Array[T.any(Symbol, String)])] The default values.
|
|
55
|
+
# @return [void]
|
|
56
|
+
sig { params(column_name: T.any(Symbol, String), mapping: T::Hash[Symbol, Integer], default: T.nilable(T::Array[T.any(Symbol, String)])).void }
|
|
57
|
+
def initialize(column_name, mapping, default)
|
|
58
|
+
@column_name = T.let(column_name.to_sym, Symbol)
|
|
59
|
+
@mapping = T.let(mapping, T::Hash[Symbol, Integer])
|
|
60
|
+
@default = T.let(default || [], T::Array[T.any(Symbol, String)])
|
|
61
|
+
@known_mask = T.let(mapping.values.reduce(0) { |mask, pos| mask | (1 << pos) }, Integer)
|
|
62
|
+
@mapping_strings = T.let(mapping.keys.map(&:to_s).freeze, T::Array[String])
|
|
63
|
+
@deserializing_depth = T.let(0, Integer)
|
|
64
|
+
super()
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Casts a value to a frozen array of symbols or strings.
|
|
68
|
+
# @param value [T.untyped] The value to cast.
|
|
69
|
+
# @return [T.nilable(T::Array[T.any(Symbol, String)])] The casted array or nil.
|
|
70
|
+
sig { override.params(value: T.untyped).returns(T.nilable(T::Array[T.any(Symbol, String)])) }
|
|
71
|
+
def cast(value)
|
|
72
|
+
return nil if value.nil?
|
|
73
|
+
return deserialize(value) if value.is_a?(Integer)
|
|
74
|
+
|
|
75
|
+
return value if value.is_a?(Array) && value.frozen? && value.instance_variable_defined?(:@_bitwise_raw_value)
|
|
76
|
+
|
|
77
|
+
# Strip empty strings and nil
|
|
78
|
+
arr = Kernel.Array(value).dup
|
|
79
|
+
arr.reject! { |v| v.nil? || v == '' }
|
|
80
|
+
|
|
81
|
+
# Array RAM Exhaustion Defense: Ceil at 100 elements
|
|
82
|
+
Kernel.raise ArgumentError, 'Array size cannot exceed 100' if arr.size > 100
|
|
83
|
+
|
|
84
|
+
processed = arr.map do |val|
|
|
85
|
+
val_str = val.to_s
|
|
86
|
+
if @mapping_strings.include?(val_str)
|
|
87
|
+
val_str.to_sym
|
|
88
|
+
else
|
|
89
|
+
val_str
|
|
90
|
+
end
|
|
91
|
+
end.uniq(&:to_s)
|
|
92
|
+
|
|
93
|
+
# Preserve raw value if present
|
|
94
|
+
raw = if value.is_a?(Array) && value.instance_variable_defined?(:@_bitwise_raw_value)
|
|
95
|
+
value.instance_variable_get(:@_bitwise_raw_value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
processed.instance_variable_set(:@_bitwise_raw_value, raw) if raw
|
|
99
|
+
processed.freeze
|
|
100
|
+
processed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Deserializes a database integer value into an array of symbols or strings.
|
|
104
|
+
# @param value [T.untyped] The raw value from the database.
|
|
105
|
+
# @return [T::Array[T.any(Symbol, String)]] The array of symbols/strings represented by the bitmask.
|
|
106
|
+
sig { override.params(value: T.untyped).returns(T::Array[T.any(Symbol, String)]) }
|
|
107
|
+
def deserialize(value)
|
|
108
|
+
if @deserializing_depth >= 2
|
|
109
|
+
Kernel.raise Error, 'System recursion limit exceeded in deserialize'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@deserializing_depth += 1
|
|
113
|
+
begin
|
|
114
|
+
if value.nil?
|
|
115
|
+
return [] unless default.any?
|
|
116
|
+
|
|
117
|
+
default_mask = 0
|
|
118
|
+
default.each do |val|
|
|
119
|
+
pos = @mapping[val.to_s.to_sym]
|
|
120
|
+
default_mask |= (1 << pos) if pos
|
|
121
|
+
end
|
|
122
|
+
return deserialize(default_mask)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if value.is_a?(Array)
|
|
126
|
+
raw = value.instance_variable_defined?(:@_bitwise_raw_value) ? value.instance_variable_get(:@_bitwise_raw_value) : serialize(value)
|
|
127
|
+
array = cast(value)
|
|
128
|
+
Kernel.raise Error, 'Casting failed' if array.nil?
|
|
129
|
+
array_dup = array.dup
|
|
130
|
+
array_dup.instance_variable_set(:@_bitwise_raw_value, raw)
|
|
131
|
+
return array_dup.freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# SQLite raw string coercion
|
|
135
|
+
raw_value = value.to_i
|
|
136
|
+
|
|
137
|
+
array = []
|
|
138
|
+
@mapping.each do |sym, bit_position|
|
|
139
|
+
array << sym if raw_value.anybits?(1 << bit_position)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
array.instance_variable_set(:@_bitwise_raw_value, raw_value)
|
|
143
|
+
array.freeze
|
|
144
|
+
array
|
|
145
|
+
ensure
|
|
146
|
+
@deserializing_depth -= 1
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Serializes an array of symbols into a database integer bitmask.
|
|
151
|
+
# @param value [T.untyped] The value to serialize.
|
|
152
|
+
# @return [T.nilable(Integer)] The resulting integer bitmask.
|
|
153
|
+
sig { override.params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
154
|
+
def serialize(value)
|
|
155
|
+
return nil if value.nil?
|
|
156
|
+
return value if value.is_a?(Integer)
|
|
157
|
+
|
|
158
|
+
new_mask = 0
|
|
159
|
+
Kernel.Array(value).each do |val|
|
|
160
|
+
val_str = val.to_s
|
|
161
|
+
if @mapping_strings.include?(val_str)
|
|
162
|
+
sym = val_str.to_sym
|
|
163
|
+
new_mask |= (1 << T.unsafe(@mapping[sym]))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
raw_val_ivar = if value.is_a?(Array) && value.instance_variable_defined?(:@_bitwise_raw_value)
|
|
168
|
+
value.instance_variable_get(:@_bitwise_raw_value)
|
|
169
|
+
else
|
|
170
|
+
0
|
|
171
|
+
end
|
|
172
|
+
raw_value = if raw_val_ivar.is_a?(Integer)
|
|
173
|
+
raw_val_ivar
|
|
174
|
+
elsif raw_val_ivar.respond_to?(:to_i)
|
|
175
|
+
raw_val_ivar.to_i
|
|
176
|
+
else
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
(raw_value & ~@known_mask) | new_mask
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
# rubocop:enable Metrics/ClassLength
|
|
184
|
+
|
|
185
|
+
# Prepended to ActiveRecord::Relation to handle batch updates and where poisoning
|
|
186
|
+
module RelationExtension
|
|
187
|
+
extend T::Sig
|
|
188
|
+
|
|
189
|
+
# Intercepts update_all to serialize bitwise attribute arrays to integers before updating.
|
|
190
|
+
# @param updates [T.untyped]
|
|
191
|
+
# @return [T.untyped]
|
|
192
|
+
sig { params(updates: T.untyped).returns(T.untyped) }
|
|
193
|
+
def update_all(updates)
|
|
194
|
+
if updates.is_a?(Hash) && T.unsafe(self).klass.respond_to?(:bitwise_definitions)
|
|
195
|
+
processed_updates = updates.dup
|
|
196
|
+
T.unsafe(self).klass.bitwise_definitions.each_key do |column_name|
|
|
197
|
+
[column_name, column_name.to_s].each do |key|
|
|
198
|
+
next unless processed_updates.key?(key)
|
|
199
|
+
|
|
200
|
+
val = processed_updates[key]
|
|
201
|
+
next if val.is_a?(Integer)
|
|
202
|
+
|
|
203
|
+
typecaster = T.unsafe(self).klass.attribute_types[column_name.to_s]
|
|
204
|
+
processed_updates[key] = typecaster.serialize(typecaster.cast(val)) if typecaster.is_a?(ActiveRecord::Bitwise::Type)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
super(processed_updates)
|
|
208
|
+
else
|
|
209
|
+
super
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Intercepts where to prevent direct querying of bitwise columns.
|
|
214
|
+
# @note This guard only catches Hash-based queries. Arel-based queries (e.g.,
|
|
215
|
+
# `User.where(User.arel_table[:roles].eq(1))`) bypass this check by design,
|
|
216
|
+
# since Arel users are assumed to understand bitmask semantics.
|
|
217
|
+
# @param args [T.untyped]
|
|
218
|
+
# @return [T.untyped]
|
|
219
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
220
|
+
def where(*args)
|
|
221
|
+
check_bitwise_query!(args.first) if args.first.is_a?(Hash) && T.unsafe(self).klass.respond_to?(:bitwise_definitions)
|
|
222
|
+
super
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Validates that bitwise columns are not directly queried.
|
|
228
|
+
# @param hash [T::Hash[T.untyped, T.untyped]]
|
|
229
|
+
# @return [void]
|
|
230
|
+
sig { params(hash: T::Hash[T.untyped, T.untyped]).void }
|
|
231
|
+
def check_bitwise_query!(hash)
|
|
232
|
+
T.unsafe(self).klass.bitwise_definitions.each_key do |column_name|
|
|
233
|
+
[column_name, column_name.to_s].each do |key|
|
|
234
|
+
if hash.key?(key)
|
|
235
|
+
Kernel.raise ActiveRecord::Bitwise::NotSupportedError,
|
|
236
|
+
"Direct querying of bitwise column #{column_name} via where is not supported. Use with_#{column_name} or without_#{column_name} scopes instead."
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Prepended to ActiveRecord::QueryMethods::WhereChain to handle negation where poisoning
|
|
244
|
+
module WhereChainExtension
|
|
245
|
+
extend T::Sig
|
|
246
|
+
|
|
247
|
+
# Intercepts not to prevent direct querying of negated bitwise columns.
|
|
248
|
+
# @param args [T.untyped]
|
|
249
|
+
# @return [T.untyped]
|
|
250
|
+
sig { params(args: T.untyped).returns(T.untyped) }
|
|
251
|
+
def not(*args)
|
|
252
|
+
scope = T.unsafe(self).instance_variable_get(:@scope)
|
|
253
|
+
if args.first.is_a?(Hash) && scope && T.unsafe(scope).klass.respond_to?(:bitwise_definitions)
|
|
254
|
+
T.unsafe(scope).klass.bitwise_definitions.each_key do |column_name|
|
|
255
|
+
[column_name, column_name.to_s].each do |key|
|
|
256
|
+
if args.first.key?(key)
|
|
257
|
+
Kernel.raise ActiveRecord::Bitwise::NotSupportedError,
|
|
258
|
+
"Direct querying of bitwise column #{column_name} via where.not is not supported. Use without_#{column_name} scope instead."
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
super
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Included/extended in ActiveRecord models to provide macro bitwise capabilities
|
|
268
|
+
#
|
|
269
|
+
# @!method self.add_column!(*values, records:)
|
|
270
|
+
# Class-level atomic method to add values to specific records.
|
|
271
|
+
# @!method self.remove_column!(*values, records:)
|
|
272
|
+
# Class-level atomic method to remove values from specific records.
|
|
273
|
+
# @!method self.bitwise_schema(col)
|
|
274
|
+
# Returns the mapping schema for the specified bitwise column.
|
|
275
|
+
# @!scope class
|
|
276
|
+
# @!method with_column(*values)
|
|
277
|
+
# Scope to find records with all specified values.
|
|
278
|
+
# @!method with_any_column(*values)
|
|
279
|
+
# Scope to find records with any of the specified values.
|
|
280
|
+
# @!method with_exact_column(*values)
|
|
281
|
+
# Scope to find records with exactly the specified values.
|
|
282
|
+
# @!method without_column(*values)
|
|
283
|
+
# Scope to find records without any of the specified values.
|
|
284
|
+
# @!method add_singular!(*values)
|
|
285
|
+
# Instance-level atomic method to add values.
|
|
286
|
+
# @note This method wraps the database update in a transaction and uses locking to prevent TOCTOU races.
|
|
287
|
+
# @!method add_column!(*values)
|
|
288
|
+
# Alias for add_singular!.
|
|
289
|
+
# @!method remove_singular!(*values)
|
|
290
|
+
# Instance-level atomic method to remove values.
|
|
291
|
+
# @note This method wraps the database update in a transaction and uses locking to prevent TOCTOU races.
|
|
292
|
+
# @!method remove_column!(*values)
|
|
293
|
+
# Alias for remove_singular!.
|
|
294
|
+
module Model
|
|
295
|
+
extend T::Sig
|
|
296
|
+
|
|
297
|
+
# Defines bitwise attributes, getters, setters, scopes, and helper methods.
|
|
298
|
+
# @param column_name [T.any(Symbol, String)] The database column name.
|
|
299
|
+
# @param mapping [T.any(T::Hash[T.any(Symbol, String), T.any(Integer, String)], T::Array[T.any(Symbol, String)])] The enum/boolean value mapping.
|
|
300
|
+
# @param default [T.nilable(T::Array[T.any(Symbol, String)])] Optional default values.
|
|
301
|
+
# @param prefix [T.nilable(T.any(Symbol, String, T::Boolean))] Optional prefix for method names.
|
|
302
|
+
# @param suffix [T.nilable(T.any(Symbol, String, T::Boolean))] Optional suffix for method names.
|
|
303
|
+
# @return [void]
|
|
304
|
+
sig do
|
|
305
|
+
params(
|
|
306
|
+
column_name: T.any(Symbol, String),
|
|
307
|
+
mapping: T.any(T::Hash[T.any(Symbol, String), T.any(Integer, String)], T::Array[T.any(Symbol, String)]),
|
|
308
|
+
default: T.nilable(T::Array[T.any(Symbol, String)]),
|
|
309
|
+
prefix: T.nilable(T.any(Symbol, String, T::Boolean)),
|
|
310
|
+
suffix: T.nilable(T.any(Symbol, String, T::Boolean))
|
|
311
|
+
).void
|
|
312
|
+
end
|
|
313
|
+
def bitwise(column_name, mapping, default: nil, prefix: nil, suffix: nil)
|
|
314
|
+
normalized_mapping = if mapping.is_a?(Array)
|
|
315
|
+
mapping.each_with_index.with_object({}) { |(k, idx), h| h[k.to_sym] = idx }
|
|
316
|
+
elsif mapping.is_a?(Hash)
|
|
317
|
+
mapping.each_with_object({}) { |(k, v), h| h[k.to_sym] = v.to_i }
|
|
318
|
+
else
|
|
319
|
+
Kernel.raise ArgumentError, 'Mapping must be a Hash or an Array'
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
default_val = default || []
|
|
323
|
+
mapping_strings = normalized_mapping.keys.map(&:to_s)
|
|
324
|
+
T.unsafe(self).bitwise_definitions = T.unsafe(self).bitwise_definitions.dup
|
|
325
|
+
T.unsafe(self).bitwise_definitions[column_name.to_sym] = {
|
|
326
|
+
mapping: normalized_mapping,
|
|
327
|
+
default: default_val,
|
|
328
|
+
prefix: prefix,
|
|
329
|
+
suffix: suffix,
|
|
330
|
+
validated: false
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
T.unsafe(self).attribute column_name, ActiveRecord::Bitwise::Type.new(column_name, normalized_mapping, default_val), default: default_val
|
|
334
|
+
|
|
335
|
+
# Define getter
|
|
336
|
+
T.unsafe(self).define_method(column_name) do
|
|
337
|
+
raw_values = T.unsafe(self).instance_variable_get(:@_bitwise_raw_values)
|
|
338
|
+
unless raw_values
|
|
339
|
+
raw_values = {}
|
|
340
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, raw_values)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
unless raw_values.key?(column_name.to_sym)
|
|
344
|
+
raw_before = T.unsafe(self).read_attribute_before_type_cast(column_name)
|
|
345
|
+
raw_values[column_name.to_sym] = if raw_before.is_a?(Integer)
|
|
346
|
+
raw_before
|
|
347
|
+
elsif raw_before.respond_to?(:to_i)
|
|
348
|
+
raw_before.to_i
|
|
349
|
+
else
|
|
350
|
+
0
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
val = super()
|
|
355
|
+
val = T.unsafe(self).class.attribute_types[column_name.to_s].deserialize(nil) if val.nil?
|
|
356
|
+
|
|
357
|
+
if val.is_a?(Array) && !val.frozen?
|
|
358
|
+
val = val.dup
|
|
359
|
+
val.instance_variable_set(:@_bitwise_raw_value, raw_values[column_name.to_sym])
|
|
360
|
+
val.freeze
|
|
361
|
+
elsif val.is_a?(Array) && val.frozen? && !val.instance_variable_defined?(:@_bitwise_raw_value)
|
|
362
|
+
unfrozen = val.dup
|
|
363
|
+
unfrozen.instance_variable_set(:@_bitwise_raw_value, raw_values[column_name.to_sym])
|
|
364
|
+
unfrozen.freeze
|
|
365
|
+
val = unfrozen
|
|
366
|
+
end
|
|
367
|
+
val
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Define setter
|
|
371
|
+
T.unsafe(self).define_method("#{column_name}=") do |new_value|
|
|
372
|
+
raw_values = T.unsafe(self).instance_variable_get(:@_bitwise_raw_values)
|
|
373
|
+
unless raw_values
|
|
374
|
+
raw_values = {}
|
|
375
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, raw_values)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
unless raw_values.key?(column_name.to_sym)
|
|
379
|
+
raw_before = T.unsafe(self).read_attribute_before_type_cast(column_name)
|
|
380
|
+
raw_values[column_name.to_sym] = if raw_before.is_a?(Integer)
|
|
381
|
+
raw_before
|
|
382
|
+
elsif raw_before.respond_to?(:to_i)
|
|
383
|
+
raw_before.to_i
|
|
384
|
+
else
|
|
385
|
+
0
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
current_raw = raw_values[column_name.to_sym]
|
|
390
|
+
typecaster = T.unsafe(self).class.attribute_types[column_name.to_s]
|
|
391
|
+
casted = typecaster.cast(new_value)
|
|
392
|
+
|
|
393
|
+
if casted.is_a?(Array)
|
|
394
|
+
unfrozen = casted.dup
|
|
395
|
+
unfrozen.instance_variable_set(:@_bitwise_raw_value, current_raw)
|
|
396
|
+
unfrozen.freeze
|
|
397
|
+
casted = unfrozen
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
super(casted)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Define prefix/suffix methods
|
|
404
|
+
prefix_str = case prefix
|
|
405
|
+
when true then "#{T.unsafe(column_name.to_s).singularize}_"
|
|
406
|
+
when Symbol, String then "#{prefix}_"
|
|
407
|
+
else ''
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
suffix_str = case suffix
|
|
411
|
+
when true then "_#{T.unsafe(column_name.to_s).singularize}"
|
|
412
|
+
when Symbol, String then "_#{suffix}"
|
|
413
|
+
else ''
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
conflicting_methods = ActiveRecord::Base.instance_methods +
|
|
417
|
+
ActiveRecord::Base.private_instance_methods +
|
|
418
|
+
ActiveRecord::Base.protected_instance_methods
|
|
419
|
+
|
|
420
|
+
normalized_mapping.each_key do |key|
|
|
421
|
+
method_name = "#{prefix_str}#{key}#{suffix_str}"
|
|
422
|
+
|
|
423
|
+
# Assert method collisions
|
|
424
|
+
generated = ["#{method_name}?", "#{method_name}=", "#{method_name}!"]
|
|
425
|
+
generated.each do |m|
|
|
426
|
+
if conflicting_methods.include?(m.to_sym)
|
|
427
|
+
Kernel.raise ArgumentError,
|
|
428
|
+
"Bitwise column #{column_name} mapping key #{key} generates method ##{m} which collides with core ActiveRecord::Base instance methods."
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
T.unsafe(self).define_method("#{method_name}?") do
|
|
433
|
+
current_values = Kernel.Array(T.unsafe(self).public_send(column_name))
|
|
434
|
+
current_values.include?(key)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
T.unsafe(self).define_method("#{method_name}=") do |val|
|
|
438
|
+
original_array = T.unsafe(self).public_send(column_name)
|
|
439
|
+
current_values = Kernel.Array(original_array).dup
|
|
440
|
+
if original_array.is_a?(Array) && original_array.instance_variable_defined?(:@_bitwise_raw_value)
|
|
441
|
+
current_values.instance_variable_set(:@_bitwise_raw_value, original_array.instance_variable_get(:@_bitwise_raw_value))
|
|
442
|
+
end
|
|
443
|
+
if val
|
|
444
|
+
current_values << key unless current_values.include?(key)
|
|
445
|
+
else
|
|
446
|
+
current_values.delete(key)
|
|
447
|
+
end
|
|
448
|
+
T.unsafe(self).public_send("#{column_name}=", current_values)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# @note This is a convenience wrapper that performs a full `save!` cycle
|
|
452
|
+
# (validations, callbacks, dirty tracking). It is NOT an atomic SQL operation.
|
|
453
|
+
# If a validation on another attribute fails, this will raise
|
|
454
|
+
# `ActiveRecord::RecordInvalid`. For truly atomic operations, use the
|
|
455
|
+
# instance-level `add_<singular>!` method instead.
|
|
456
|
+
T.unsafe(self).define_method("#{method_name}!") do
|
|
457
|
+
current_values = Kernel.Array(T.unsafe(self).public_send(column_name)).dup
|
|
458
|
+
current_values << key unless current_values.include?(key)
|
|
459
|
+
T.unsafe(self).public_send("#{column_name}=", current_values)
|
|
460
|
+
T.unsafe(self).save!
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
T.unsafe(self).singleton_class.class_eval do
|
|
465
|
+
T.unsafe(self).define_method("add_#{column_name}!") do |*values, records:|
|
|
466
|
+
ids = Kernel.Array(records).map { |r| r.respond_to?(:id) ? T.unsafe(r).id : r }
|
|
467
|
+
return if ids.empty?
|
|
468
|
+
|
|
469
|
+
mask = values.flatten.filter_map do |v|
|
|
470
|
+
val_str = v.to_s
|
|
471
|
+
mapping_strings.include?(val_str) ? normalized_mapping[val_str.to_sym] : nil
|
|
472
|
+
end.reduce(0) { |m, pos| m | (1 << pos) }
|
|
473
|
+
return if mask.zero?
|
|
474
|
+
|
|
475
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
476
|
+
T.unsafe(self).where(id: ids).update_all(["#{quoted_col} = COALESCE(#{quoted_col}, 0) | ?", mask])
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
T.unsafe(self).define_method("remove_#{column_name}!") do |*values, records:|
|
|
480
|
+
ids = Kernel.Array(records).map { |r| r.respond_to?(:id) ? T.unsafe(r).id : r }
|
|
481
|
+
return if ids.empty?
|
|
482
|
+
|
|
483
|
+
mask = values.flatten.filter_map do |v|
|
|
484
|
+
val_str = v.to_s
|
|
485
|
+
mapping_strings.include?(val_str) ? normalized_mapping[val_str.to_sym] : nil
|
|
486
|
+
end.reduce(0) { |m, pos| m | (1 << pos) }
|
|
487
|
+
return if mask.zero?
|
|
488
|
+
|
|
489
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
490
|
+
T.unsafe(self).where(id: ids).update_all(["#{quoted_col} = COALESCE(#{quoted_col}, 0) - (COALESCE(#{quoted_col}, 0) & ?)", mask])
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Define Instance-level atomic methods
|
|
495
|
+
# @note These methods perform the atomic SQL update, then read back the new value
|
|
496
|
+
# via a separate `pluck` query. There is a small TOCTOU window between the
|
|
497
|
+
# `update_all` and the `pluck` where another concurrent thread could modify
|
|
498
|
+
# the same row, causing the in-memory state to diverge from the DB. For
|
|
499
|
+
# absolute consistency, wrap usage in a transaction or re-read via `reload`.
|
|
500
|
+
singular_col = T.unsafe(column_name.to_s).singularize
|
|
501
|
+
|
|
502
|
+
T.unsafe(self).define_method("add_#{singular_col}!") do |*values|
|
|
503
|
+
T.unsafe(self).class.transaction do
|
|
504
|
+
T.unsafe(self).class.public_send("add_#{column_name}!", *T.unsafe(values), records: T.unsafe(self).id)
|
|
505
|
+
new_db_value = T.unsafe(self).class.where(id: T.unsafe(self).id).lock(true).pluck(column_name.to_sym).first || 0
|
|
506
|
+
raw_values = T.unsafe(self).instance_variable_get(:@_bitwise_raw_values)
|
|
507
|
+
unless raw_values
|
|
508
|
+
raw_values = {}
|
|
509
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, raw_values)
|
|
510
|
+
end
|
|
511
|
+
raw_values[column_name.to_sym] = new_db_value
|
|
512
|
+
|
|
513
|
+
casted = T.unsafe(self).class.attribute_types[column_name.to_s].deserialize(new_db_value)
|
|
514
|
+
T.unsafe(self).write_attribute(column_name, casted)
|
|
515
|
+
T.unsafe(self).clear_attribute_changes([column_name.to_s]) if T.unsafe(self).respond_to?(:clear_attribute_changes)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
T.unsafe(self).define_method("add_#{column_name}!") do |*values|
|
|
520
|
+
T.unsafe(self).send("add_#{singular_col}!", *T.unsafe(values))
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
T.unsafe(self).define_method("remove_#{singular_col}!") do |*values|
|
|
524
|
+
T.unsafe(self).class.transaction do
|
|
525
|
+
T.unsafe(self).class.public_send("remove_#{column_name}!", *T.unsafe(values), records: T.unsafe(self).id)
|
|
526
|
+
new_db_value = T.unsafe(self).class.where(id: T.unsafe(self).id).lock(true).pluck(column_name.to_sym).first || 0
|
|
527
|
+
raw_values = T.unsafe(self).instance_variable_get(:@_bitwise_raw_values)
|
|
528
|
+
unless raw_values
|
|
529
|
+
raw_values = {}
|
|
530
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, raw_values)
|
|
531
|
+
end
|
|
532
|
+
raw_values[column_name.to_sym] = new_db_value
|
|
533
|
+
|
|
534
|
+
casted = T.unsafe(self).class.attribute_types[column_name.to_s].deserialize(new_db_value)
|
|
535
|
+
T.unsafe(self).write_attribute(column_name, casted)
|
|
536
|
+
T.unsafe(self).clear_attribute_changes([column_name.to_s]) if T.unsafe(self).respond_to?(:clear_attribute_changes)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
T.unsafe(self).define_method("remove_#{column_name}!") do |*values|
|
|
541
|
+
T.unsafe(self).send("remove_#{singular_col}!", *T.unsafe(values))
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Define Scopes
|
|
545
|
+
# @note `with_` and `with_any_` silently drop unrecognized values, returning
|
|
546
|
+
# `where('1=0')` (empty) only if ALL values are invalid. `with_exact_` returns
|
|
547
|
+
# `where('1=0')` if ANY value is invalid (strict validation). `without_` drops
|
|
548
|
+
# unrecognized values and returns `all` if none are valid.
|
|
549
|
+
T.unsafe(self).scope "with_#{column_name}", Kernel.lambda { |*values|
|
|
550
|
+
if values.empty?
|
|
551
|
+
T.unsafe(self).connection.quote_column_name(column_name)
|
|
552
|
+
return T.unsafe(self).all
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
cleaned = values.flatten.reject { |v| v.nil? || v == '' }
|
|
556
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
557
|
+
if cleaned.include?(0) || cleaned.include?('0') || cleaned.empty?
|
|
558
|
+
return T.unsafe(self).where("#{quoted_col} = 0")
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
valid = cleaned.select { |v| mapping_strings.include?(v.to_s) }.map { |v| v.to_s.to_sym }
|
|
562
|
+
return T.unsafe(self).where('1=0') if valid.empty?
|
|
563
|
+
|
|
564
|
+
mask = valid.reduce(0) { |m, v| m | (1 << normalized_mapping[v]) }
|
|
565
|
+
T.unsafe(self).where("#{quoted_col} & ? = ?", mask, mask)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
T.unsafe(self).scope "with_any_#{column_name}", Kernel.lambda { |*values|
|
|
569
|
+
cleaned = values.flatten.reject { |v| v.nil? || v == '' }
|
|
570
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
571
|
+
return T.unsafe(self).all if cleaned.empty?
|
|
572
|
+
|
|
573
|
+
valid = cleaned.select { |v| mapping_strings.include?(v.to_s) }.map { |v| v.to_s.to_sym }
|
|
574
|
+
return T.unsafe(self).where('1=0') if valid.empty?
|
|
575
|
+
|
|
576
|
+
mask = valid.reduce(0) { |m, v| m | (1 << normalized_mapping[v]) }
|
|
577
|
+
T.unsafe(self).where("#{quoted_col} & ? > 0", mask)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
T.unsafe(self).scope "with_exact_#{column_name}", Kernel.lambda { |*values|
|
|
581
|
+
cleaned = values.flatten.reject { |v| v.nil? || v == '' }
|
|
582
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
583
|
+
|
|
584
|
+
return T.unsafe(self).where("#{quoted_col} = 0") if cleaned.empty?
|
|
585
|
+
|
|
586
|
+
valid = cleaned.select { |v| mapping_strings.include?(v.to_s) }.map { |v| v.to_s.to_sym }
|
|
587
|
+
|
|
588
|
+
return T.unsafe(self).where('1=0') if valid.size < cleaned.size
|
|
589
|
+
|
|
590
|
+
mask = valid.reduce(0) { |m, v| m | (1 << normalized_mapping[v]) }
|
|
591
|
+
T.unsafe(self).where("#{quoted_col} = ?", mask)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
T.unsafe(self).scope "without_#{column_name}", Kernel.lambda { |*values|
|
|
595
|
+
cleaned = values.flatten.reject { |v| v.nil? || v == '' }
|
|
596
|
+
quoted_col = T.unsafe(self).connection.quote_column_name(column_name)
|
|
597
|
+
return T.unsafe(self).all if cleaned.empty?
|
|
598
|
+
|
|
599
|
+
valid = cleaned.select { |v| mapping_strings.include?(v.to_s) }.map { |v| v.to_s.to_sym }
|
|
600
|
+
return T.unsafe(self).all if valid.empty?
|
|
601
|
+
|
|
602
|
+
mask = valid.reduce(0) { |m, v| m | (1 << normalized_mapping[v]) }
|
|
603
|
+
T.unsafe(self).where("#{quoted_col} & ? = 0", mask)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
# Define schema helper class method
|
|
607
|
+
T.unsafe(self).singleton_class.class_eval do
|
|
608
|
+
T.unsafe(self).define_method(:bitwise_schema) do |col|
|
|
609
|
+
T.unsafe(self).bitwise_definitions.dig(col.to_sym, :mapping)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Register callbacks (guarded to prevent duplicate registration when
|
|
614
|
+
# multiple bitwise columns are defined on the same model)
|
|
615
|
+
T.unsafe(self).class_eval do
|
|
616
|
+
any_ancestor_registered = T.unsafe(self).ancestors.any? do |ancestor|
|
|
617
|
+
ancestor.instance_variable_defined?(:@_bitwise_callbacks_registered) &&
|
|
618
|
+
ancestor.instance_variable_get(:@_bitwise_callbacks_registered)
|
|
619
|
+
end
|
|
620
|
+
unless any_ancestor_registered
|
|
621
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_callbacks_registered, true)
|
|
622
|
+
|
|
623
|
+
T.unsafe(self).after_initialize :_validate_bitwise_column_type_and_bounds
|
|
624
|
+
T.unsafe(self).after_save :_reset_bitwise_raw_value_caches
|
|
625
|
+
|
|
626
|
+
T.unsafe(self).define_method(:clear_bitwise_raw_values_cache!) do
|
|
627
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, {})
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
T.unsafe(self).define_method(:reload) do |*args|
|
|
631
|
+
T.unsafe(self).clear_bitwise_raw_values_cache!
|
|
632
|
+
super(*args)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
T.unsafe(self).define_method(:_validate_bitwise_column_type_and_bounds) do
|
|
636
|
+
if T.unsafe(self).class.instance_variable_defined?(:@_bitwise_columns_validated) &&
|
|
637
|
+
T.unsafe(self).class.instance_variable_get(:@_bitwise_columns_validated) &&
|
|
638
|
+
T.unsafe(self).class.bitwise_definitions.values.all? { |config| config[:validated] }
|
|
639
|
+
return
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
all_validated = T.let(true, T::Boolean)
|
|
643
|
+
T.unsafe(self).class.bitwise_definitions.each do |col, config|
|
|
644
|
+
next if T.unsafe(config)[:validated]
|
|
645
|
+
|
|
646
|
+
begin
|
|
647
|
+
next unless T.unsafe(self).class.connection.active? && T.unsafe(self).class.table_exists?
|
|
648
|
+
|
|
649
|
+
column = T.unsafe(self).class.columns_hash[col.to_s]
|
|
650
|
+
unless column && T.unsafe(column).type == :integer
|
|
651
|
+
Kernel.raise ArgumentError,
|
|
652
|
+
"Bitwise column #{col} must be an integer database column"
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
max_bits = case T.unsafe(column).limit
|
|
656
|
+
when 1 then 7
|
|
657
|
+
when 2 then 15
|
|
658
|
+
when 4, nil then 31
|
|
659
|
+
when 8 then 63
|
|
660
|
+
else 31
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
max_assigned_position = config[:mapping].values.max || 0
|
|
664
|
+
if max_assigned_position >= max_bits
|
|
665
|
+
Kernel.raise ArgumentError,
|
|
666
|
+
"Bitwise column #{col} has limit of #{T.unsafe(column).limit || 4} bytes (max #{max_bits} flags), but mapping requires bit shift position #{max_assigned_position}."
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
T.unsafe(config)[:validated] = true
|
|
670
|
+
rescue ArgumentError => e
|
|
671
|
+
Kernel.raise e
|
|
672
|
+
rescue StandardError
|
|
673
|
+
all_validated = false
|
|
674
|
+
# Ignore database missing errors
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
return unless all_validated && T.unsafe(self).class.bitwise_definitions.present?
|
|
679
|
+
|
|
680
|
+
T.unsafe(self).class.instance_variable_set(:@_bitwise_columns_validated, true)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
T.unsafe(self).define_method(:_reset_bitwise_raw_value_caches) do
|
|
684
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, {})
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
T.unsafe(self).define_method(:initialize_dup) do |other|
|
|
688
|
+
super(other)
|
|
689
|
+
T.unsafe(self).instance_variable_set(:@_bitwise_raw_values, {})
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
nil
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Automatically extends the base class with bitwise definitions attribute.
|
|
697
|
+
# @param base [T::Module[T.anything]] The class extending this module.
|
|
698
|
+
# @return [void]
|
|
699
|
+
sig { params(base: T::Module[T.anything]).void }
|
|
700
|
+
def self.extended(base)
|
|
701
|
+
T.unsafe(base).class_attribute :bitwise_definitions, default: {}
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
T.unsafe(ActiveSupport).on_load(:active_record) do
|
|
708
|
+
extend ActiveRecord::Bitwise::Model
|
|
709
|
+
ActiveRecord::Relation.prepend(ActiveRecord::Bitwise::RelationExtension)
|
|
710
|
+
ActiveRecord::QueryMethods::WhereChain.prepend(ActiveRecord::Bitwise::WhereChainExtension)
|
|
711
|
+
end
|