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.
@@ -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