foobara 0.0.130 → 0.0.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5dd322e7389be1e600f98133b9e92a177f1d14a1b2c0d4200e7b9630fd023983
4
- data.tar.gz: 6518a4d6ac731b1973809640058b8e793ebcd9dc0d014827713fc2466fe209d9
3
+ metadata.gz: c95217bebc01ab27c28cdf572b5e19eb2902fd68b1bb4d7328c093b02cfe8dbf
4
+ data.tar.gz: 76d32046c22f96ebf9e73d738aa4154fea3edb740e66fab71954abbd8594a35c
5
5
  SHA512:
6
- metadata.gz: 96d8ca1ee65877ae7315052668cdff9c247ee778f5d285d4828145b3c69f06402fc97b4085744315f76b30dad36d5a9bd7f4a753108a1195950c4676c9cf16e1
7
- data.tar.gz: a3ee1b0364e55f320ea2272b17f6a49d0a5e5a4d7406c3f9aa65fb8b04cdbf7b5c1169db6ae06adb3851edc62ae595614011a95b6b0ffa4767e672466f2c855c
6
+ metadata.gz: 148fe1548cc4731673ffa299fe1d5ef96a8377cdbc613e3aeff989b16efbc250beea9b58f1b3b1727d0b399b5f1925186c1ac97908c8e2bf6113f4ff0cba1835
7
+ data.tar.gz: e49bad06d6f305b4e1716fd56fa47298028c6b699977e95417e9013c95f3a25a90ca8a9b2d96b8a11c9fb5989b56587ee87d7b719d2397b90d95210168a594ce
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.0.131] - 2025-06-16
2
+
3
+ - Extract InMemoryMinimal crud driver specs to foobara-crud-driver-spec-helpers gem
4
+ - Better support for nested transactions
5
+ - Fix buggy actions carried out when committing/rolling back transactions
6
+ - Fix thread leaks in WeakObjectSet and test suite
7
+
1
8
  # [0.0.130] - 2025-06-06
2
9
 
3
10
  - Support using a proc as an attributes default for lazy evaluation of default values
@@ -20,7 +20,7 @@ module Foobara
20
20
  # TODO: all multiple record methods should return enumerators and code further up should only use
21
21
  # the lazy enumerator interface... to encourage that/catch bugs we will return lazy enumerators in these
22
22
  # built-in crud drivers
23
- def all
23
+ def all(page_size: nil)
24
24
  records.each_value.lazy
25
25
  end
26
26
 
@@ -4,6 +4,12 @@ module Foobara
4
4
  class EntityAttributesCrudDriver
5
5
  attr_accessor :raw_connection, :tables
6
6
 
7
+ class << self
8
+ def has_real_transactions?
9
+ false
10
+ end
11
+ end
12
+
7
13
  def initialize(connection_or_credentials = nil)
8
14
  self.raw_connection = open_connection(connection_or_credentials)
9
15
  self.tables = {}
@@ -31,7 +37,7 @@ module Foobara
31
37
  def rollback_transaction(_raw_tx)
32
38
  end
33
39
 
34
- def close_transaction(_raw_tx)
40
+ def commit_transaction(_raw_tx)
35
41
  end
36
42
 
37
43
  def table_for(entity_class)
@@ -91,7 +97,7 @@ module Foobara
91
97
  # :nocov:
92
98
  end
93
99
 
94
- def all
100
+ def all(page_size: nil)
95
101
  # :nocov:
96
102
  raise "subclass responsibility"
97
103
  # :nocov:
@@ -161,39 +167,22 @@ module Foobara
161
167
 
162
168
  def matches_attributes_filter?(attributes, attributes_filter)
163
169
  attributes_filter.all? do |attribute_name_or_path, value|
164
- value = normalize_attribute_filter_value(value)
170
+ # get the model-free type?
171
+ attribute_type = entity_class.attributes_type.type_at_path(attribute_name_or_path)
172
+
173
+ value = restore_attributes(value, attribute_type)
165
174
 
166
175
  if attribute_name_or_path.is_a?(::Array)
167
176
  values = DataPath.values_at(attribute_name_or_path, attributes)
168
177
 
169
178
  values.any? do |attribute_value|
170
- normalize_attribute_filter_value(attribute_value) == value
179
+ restore_attributes(attribute_value, attribute_type) == value
171
180
  end
172
181
  else
173
- attribute_value = attributes[attribute_name_or_path]
174
- normalize_attribute_filter_value(attribute_value) == value
175
- end
176
- end
177
- end
178
-
179
- def normalize_attribute_filter_value(value)
180
- case value
181
- when ::Array
182
- value.map { |v| normalize_attribute_filter_value(v) }
183
- when ::Hash
184
- value.to_h do |k, v|
185
- [normalize_attribute_filter_value(k), normalize_attribute_filter_value(v)]
186
- end
187
- when DetachedEntity
188
- if value.persisted?
189
- normalize_attribute_filter_value(value.primary_key)
190
- else
191
- value
182
+ attribute_value = DataPath.value_at(attribute_name_or_path, attributes)
183
+ attribute_value = restore_attributes(attribute_value, attribute_type)
184
+ attribute_value == value
192
185
  end
193
- when Model
194
- normalize_attribute_filter_value(value.attributes)
195
- else
196
- value
197
186
  end
198
187
  end
199
188
 
@@ -262,6 +251,50 @@ module Foobara
262
251
  def primary_key_attribute
263
252
  entity_class.primary_key_attribute
264
253
  end
254
+
255
+ def restore_attributes(object, type = entity_class.attributes_type)
256
+ if type.extends?(BuiltinTypes[:attributes])
257
+ object.to_h do |attribute_name, attribute_value|
258
+ attribute_type = type.type_at_path(attribute_name)
259
+ [attribute_name.to_sym, restore_attributes(attribute_value, attribute_type)]
260
+ end
261
+ elsif type.extends?(BuiltinTypes[:tuple])
262
+ # TODO: test this code path
263
+ # :nocov:
264
+ object.map.with_index do |value, index|
265
+ element_type = type.element_types[index]
266
+ restore_attributes(value, element_type)
267
+ end
268
+ # :nocov:
269
+ elsif type.extends?(BuiltinTypes[:array])
270
+ element_type = type.element_type
271
+ object.map { |value| restore_attributes(value, element_type) }
272
+ elsif type.extends?(BuiltinTypes[:entity])
273
+ if object.is_a?(Model)
274
+ if object.persisted?
275
+ object = object.primary_key
276
+ restore_attributes(object, type.target_class.primary_key_type)
277
+ else
278
+ object
279
+ end
280
+ else
281
+ restore_attributes(object, type.target_class.primary_key_type)
282
+ end
283
+ elsif type.extends?(BuiltinTypes[:model])
284
+ if object.is_a?(Model)
285
+ object = object.attributes
286
+ end
287
+ restore_attributes(object, type.element_types)
288
+ else
289
+ outcome = type.process_value(object)
290
+
291
+ if outcome.success?
292
+ outcome.result
293
+ else
294
+ object
295
+ end
296
+ end
297
+ end
265
298
  end
266
299
  end
267
300
  end
@@ -15,12 +15,20 @@ module Foobara
15
15
  end
16
16
  end
17
17
 
18
+ def open_nested!(outer_tx)
19
+ state_machine.open_nested! do
20
+ self.is_nested = true
21
+ self.raw_tx = outer_tx.raw_tx
22
+ end
23
+ end
24
+
18
25
  def flush!
19
26
  state_machine.flush! do
20
27
  each_table(&:validate!)
21
28
  each_table(&:flush_created!)
22
29
  each_table(&:flush_updated_and_hard_deleted!)
23
30
  end
31
+ entity_attributes_crud_driver.flush_transaction(raw_tx)
24
32
  rescue => e
25
33
  # :nocov:
26
34
  rollback!(e)
@@ -34,6 +42,7 @@ module Foobara
34
42
  state_machine.revert! do
35
43
  each_table(&:revert!)
36
44
  end
45
+ entity_attributes_crud_driver.revert_transaction(raw_tx)
37
46
  rescue => e
38
47
  # :nocov:
39
48
  rollback!(e)
@@ -42,11 +51,25 @@ module Foobara
42
51
  end
43
52
 
44
53
  def commit!
54
+ return commit_nested! if nested?
55
+
45
56
  state_machine.commit! do
46
- each_table(&:validate!)
47
- each_table(&:flush_created!)
48
- each_table(&:flush_updated_and_hard_deleted!)
49
- entity_attributes_crud_driver.close_transaction(raw_tx)
57
+ each_table(&:commit!)
58
+ entity_attributes_crud_driver.commit_transaction(raw_tx)
59
+ each_table(&:transaction_closed)
60
+ end
61
+ rescue => e
62
+ # :nocov:
63
+ rollback!(e)
64
+ raise
65
+ # :nocov:
66
+ end
67
+
68
+ def commit_nested!
69
+ state_machine.commit_nested! do
70
+ each_table(&:commit!)
71
+ entity_attributes_crud_driver.flush_transaction(raw_tx)
72
+ each_table(&:transaction_closed)
50
73
  end
51
74
  rescue => e
52
75
  # :nocov:
@@ -61,13 +84,32 @@ module Foobara
61
84
  end
62
85
 
63
86
  def rollback!(because_of = nil)
87
+ return rollback_nested!(because_of) if nested?
88
+
64
89
  state_machine.rollback! do
65
90
  # TODO: raise error if already flushed and if crud_driver doesn't support true transactions
66
91
  entity_attributes_crud_driver.rollback_transaction(raw_tx)
67
92
  each_table(&:rollback!)
68
- entity_attributes_crud_driver.close_transaction(raw_tx)
69
93
  end
70
94
 
95
+ each_table(&:transaction_closed)
96
+
97
+ if !because_of && (self == entity_base.current_transaction)
98
+ raise RolledBack, "intentionally rolled back"
99
+ end
100
+ rescue
101
+ state_machine.error! if state_machine.currently_open?
102
+ raise
103
+ end
104
+
105
+ def rollback_nested!(because_of = nil)
106
+ state_machine.rollback_nested! do
107
+ entity_attributes_crud_driver.revert_transaction(raw_tx)
108
+ each_table(&:revert!)
109
+ end
110
+
111
+ each_table(&:transaction_closed)
112
+
71
113
  if !because_of && (self == entity_base.current_transaction)
72
114
  raise RolledBack, "intentionally rolled back"
73
115
  end
@@ -7,6 +7,7 @@ module Foobara
7
7
  set_transition_map({
8
8
  unopened: {
9
9
  open: :open,
10
+ open_nested: :open,
10
11
  close: :closed
11
12
  },
12
13
  open: {
@@ -17,7 +18,9 @@ module Foobara
17
18
  # TODO: should we have intermediate states to quickly get out of the open state?
18
19
  rollback: :closed,
19
20
  commit: :closed,
20
- error: :closed
21
+ error: :closed,
22
+ commit_nested: :closed,
23
+ rollback_nested: :closed
21
24
  }
22
25
  })
23
26
  end
@@ -6,7 +6,7 @@ module Foobara
6
6
  include Concerns::EntityCallbackHandling
7
7
  include Concerns::TransactionTracking
8
8
 
9
- attr_accessor :state_machine, :entity_base, :raw_tx, :tables
9
+ attr_accessor :state_machine, :entity_base, :raw_tx, :tables, :is_nested
10
10
 
11
11
  def initialize(entity_base)
12
12
  self.entity_base = entity_base
@@ -162,6 +162,10 @@ module Foobara
162
162
  def perform(&)
163
163
  entity_base.using_transaction(self, &)
164
164
  end
165
+
166
+ def nested?
167
+ is_nested
168
+ end
165
169
  end
166
170
  end
167
171
  end
@@ -83,10 +83,25 @@ module Foobara
83
83
  end
84
84
 
85
85
  def rolled_back
86
+ closed
87
+ end
88
+
89
+ def committed
90
+ closed
91
+ end
92
+
93
+ def closed
86
94
  marked_hard_deleted.clear
87
95
  marked_updated.clear
88
96
  marked_created.clear
89
- tracked_records.clear
97
+ marked_loading.clear
98
+ end
99
+
100
+ # We need to clear this one separately. That's because otherwise a different table
101
+ # might flush and create a thunk if it has an association to this table but we've stopped
102
+ # tracking the record.
103
+ def transaction_closed
104
+ tracked_records.close
90
105
  end
91
106
 
92
107
  def reverted
@@ -632,6 +632,14 @@ module Foobara
632
632
  rolled_back
633
633
  end
634
634
 
635
+ def commit!
636
+ validate!
637
+ flush_created!
638
+ flush_updated_and_hard_deleted!
639
+
640
+ committed
641
+ end
642
+
635
643
  def revert!
636
644
  # TODO: could pause record tracking while doing this as a performance boost
637
645
  marked_updated.each(&:restore_without_callbacks!)
@@ -52,7 +52,31 @@ module Foobara
52
52
  Thread.inheritable_thread_local_var_set(transaction_key, transaction)
53
53
  end
54
54
 
55
- VALID_MODES = [:use_existing, :open_nested, :open_new, nil].freeze
55
+ # What types of transaction scenarios are there?
56
+ # 1. If a transaction is already open, use it as a "nested transaction", otherwise, open a new one.
57
+ # A nested transaction means that "rollback" is the same as "revert" and "commit" is the same as "flush".
58
+ # For a true
59
+ # 2. If a transaction is already open, raise an error. otherwise, open a new one.
60
+ # 3. Open a new, independent transaction, no matter what.
61
+ # 4. If a transaction is already open, use it, otherwise, open a new one.
62
+ # 5. We are outside of a transaction but have a handle on one. We want to set it as the current transaction.
63
+ # and do some work in that transaction.
64
+ # Which use cases do we probably need at the moment?
65
+ # 1. If we are running a command calling other commands, we will open transactions when needed but
66
+ # inherit any already-open transactions. Commands don't commit or flush to the already-open transactions
67
+ # that they inherit. So this feels like a "use existing" situation or a situation where we don't even
68
+ # bother calling open_transaction at all. This is the most important use-case. It can be helpful to raise
69
+ # in this situation because it is not expected that there's an existing transaction yet we're opening another.
70
+ # 2. We might have a situation where we are in one transaction but definitely want to open a new one and
71
+ # commit it ourselves and have its results committed and visible independent of the current transaction.
72
+ # So this feels like a "open new" situation where we don't want to raise an error if a transaction is
73
+ # already open.
74
+ VALID_MODES = [
75
+ :use_existing,
76
+ :open_nested,
77
+ :open_new,
78
+ nil
79
+ ].freeze
56
80
 
57
81
  def using_transaction(existing_transaction, &)
58
82
  transaction(existing_transaction:, &)
@@ -67,18 +91,28 @@ module Foobara
67
91
 
68
92
  old_transaction = current_transaction
69
93
 
70
- if old_transaction&.closed?
94
+ if old_transaction && !old_transaction.currently_open?
71
95
  old_transaction = nil
72
96
  end
73
97
 
74
- if old_transaction&.currently_open?
98
+ open_nested = false
99
+
100
+ if old_transaction
75
101
  if mode == :use_existing || existing_transaction == old_transaction
76
102
  if block_given?
77
103
  return yield old_transaction
78
104
  else
79
105
  return old_transaction
80
106
  end
81
- elsif mode != :open_nested && mode != :open_new
107
+ elsif mode == :open_nested
108
+ open_nested = true
109
+ elsif mode == :open_new
110
+ if existing_transaction
111
+ # :nocov:
112
+ raise ArgumentError, "Cannot use mode :open_new with existing_transaction:"
113
+ # :nocov:
114
+ end
115
+ else
82
116
  # :nocov:
83
117
  raise "Transaction already open. " \
84
118
  "Use mode :use_existing if you want to make use of the existing transaction. " \
@@ -96,16 +130,27 @@ module Foobara
96
130
  tx = existing_transaction
97
131
  else
98
132
  tx = Transaction.new(self)
99
- tx.open!
133
+
134
+ if open_nested
135
+ tx.open_nested!(old_transaction)
136
+ else
137
+ tx.open!
138
+ end
100
139
  end
101
140
 
102
141
  set_current_transaction(tx)
142
+
103
143
  result = yield tx
104
- tx.commit! if tx.currently_open? && !existing_transaction
144
+
145
+ if tx.currently_open? && !existing_transaction
146
+ tx.commit!
147
+ end
105
148
  result
106
149
  rescue Foobara::Persistence::EntityBase::Transaction::RolledBack # rubocop:disable Lint/SuppressedException
107
150
  rescue => e
108
- tx.rollback!(e) if tx.currently_open?
151
+ if tx.currently_open?
152
+ tx.rollback!(e)
153
+ end
109
154
  raise
110
155
  ensure
111
156
  set_current_transaction(old_transaction)
@@ -4,8 +4,6 @@ module Foobara
4
4
  # TODO: a possible optimization: have a certain number of records before the Weakref approach kicks in
5
5
  # that way we don't just immediately clear out useful information without any actual memory burden
6
6
  class WeakObjectSet
7
- class InvalidWtf < StandardError; end
8
-
9
7
  class GarbageCleaner
10
8
  attr_accessor :weak_object_set, :deactivated, :queue, :cleanup_thread
11
9
 
@@ -17,6 +15,12 @@ module Foobara
17
15
  end
18
16
 
19
17
  def cleanup_proc
18
+ if deactivated?
19
+ # :nocov:
20
+ raise "GarbageCleaner has been deactivated"
21
+ # :nocov:
22
+ end
23
+
20
24
  @cleanup_proc ||= begin
21
25
  queue = self.queue
22
26
 
@@ -35,6 +39,8 @@ module Foobara
35
39
  end
36
40
 
37
41
  def start_cleanup_thread
42
+ queue = self.queue
43
+
38
44
  self.cleanup_thread = Thread.new do
39
45
  loop do
40
46
  object_id = queue.pop
@@ -57,9 +63,16 @@ module Foobara
57
63
  end
58
64
 
59
65
  def deactivate
66
+ raise if deactivated?
67
+
60
68
  self.deactivated = true
61
69
  queue.close
70
+ # TODO: don't bother to join here outside of test suite
62
71
  cleanup_thread.join # just doing this for test suite/simplecov
72
+ @cleanup_proc = nil
73
+ @queue = nil
74
+ @weak_object_set = nil
75
+ @cleanup_thread = nil
63
76
  end
64
77
 
65
78
  def deactivated?
@@ -69,7 +82,7 @@ module Foobara
69
82
 
70
83
  include Enumerable
71
84
 
72
- attr_accessor :monitor, :key_method, :key_to_object_id, :object_id_to_key, :objects
85
+ attr_accessor :monitor, :key_method, :key_to_object_id, :object_id_to_key, :objects, :closed
73
86
  attr_writer :garbage_cleaner
74
87
 
75
88
  def initialize(key_method = nil)
@@ -136,19 +149,17 @@ module Foobara
136
149
  @garbage_cleaner ||= begin
137
150
  queue = Queue.new
138
151
 
139
- gc = GarbageCleaner.new(self, queue)
140
-
141
- ObjectSpace.define_finalizer gc do
142
- # :nocov:
143
- queue.close
144
- # :nocov:
145
- end
146
-
147
- gc
152
+ GarbageCleaner.new(self, queue)
148
153
  end
149
154
  end
150
155
 
151
156
  def <<(object)
157
+ if closed?
158
+ # :nocov:
159
+ raise "Cannot add objects to a closed WeakObjectSet"
160
+ # :nocov:
161
+ end
162
+
152
163
  object_id = object.object_id
153
164
 
154
165
  monitor.synchronize do
@@ -235,11 +246,30 @@ module Foobara
235
246
  end
236
247
  end
237
248
 
249
+ def close
250
+ raise if closed?
251
+
252
+ self.closed = true
253
+ stop_garbage_cleaner
254
+ end
255
+
256
+ def stop_garbage_cleaner
257
+ gc = nil
258
+
259
+ monitor.synchronize do
260
+ if @garbage_cleaner
261
+ gc = garbage_cleaner
262
+ self.garbage_cleaner = nil
263
+ end
264
+ end
265
+
266
+ gc&.deactivate
267
+ end
268
+
238
269
  def clear
239
270
  monitor.synchronize do
240
- garbage_cleaner.deactivate
271
+ stop_garbage_cleaner
241
272
 
242
- self.garbage_cleaner = nil
243
273
  self.objects = {}
244
274
 
245
275
  if key_method
@@ -248,5 +278,9 @@ module Foobara
248
278
  end
249
279
  end
250
280
  end
281
+
282
+ def closed?
283
+ closed
284
+ end
251
285
  end
252
286
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.130
4
+ version: 0.0.131
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Georgi
@@ -486,8 +486,8 @@ licenses:
486
486
  - MPL-2.0
487
487
  metadata:
488
488
  homepage_uri: https://foobara.com
489
- source_code_uri: https://foobara.com
490
- changelog_uri: https://foobara.com/blob/main/CHANGELOG.md
489
+ source_code_uri: https://github.com/foobara/foobara
490
+ changelog_uri: https://github.com/foobara/foobara/blob/main/CHANGELOG.md
491
491
  rubygems_mfa_required: 'true'
492
492
  rdoc_options: []
493
493
  require_paths:
@@ -529,7 +529,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
529
529
  - !ruby/object:Gem::Version
530
530
  version: '0'
531
531
  requirements: []
532
- rubygems_version: 3.6.7
532
+ rubygems_version: 3.6.9
533
533
  specification_version: 4
534
534
  summary: A command-centric and discoverable software framework with a focus on domain
535
535
  concepts and abstracting away integration code