undertow 0.2.0 → 0.2.1

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: 0b2c91ae2e1c3eafff3e52438ca4a8c2e2856fd83c2c1097708d3e7f2ed457be
4
- data.tar.gz: 385394ce28a3034f17d4e89d3d00528931f38c44a180b338c5bfb43c83b4877b
3
+ metadata.gz: bf230477188804dcf6c63e0c97c615defb3577c78f85e930449d7dba6d9d965e
4
+ data.tar.gz: 6653637c116fff79ba2a2806326882f7e7e939acdf1ca0f01a653e22530dd3c7
5
5
  SHA512:
6
- metadata.gz: 57614c035d133873d7f3c9b881bc6c59de51c0f2add8bc860eb059f955bdd4cc7a57576e99df1085576db5b64fbdfd7c6b4376136093b46ed259e2e70dc3f87b
7
- data.tar.gz: 20cca40fdfe6459d18b264050ce5db678d6d94e537d3bd19a7fbe3a61fb5b7e32bb13bd859a88d263f20d8df4473cc178d6796470d609add092d66ca60229da9
6
+ metadata.gz: 74aca2058a93a4474bd275de1ab3d077b7e83e5a6449cb0921938585821c29f39ab531240eabbfb89d28ba7ac4a73cd4f3c58183e001992847d1a3ebae6e89b2
7
+ data.tar.gz: 6c18ad42284eb95008c44bfd6240e79ae14c7d99aa50befa8c1ccb417d07286a55049c1467f6e4f66a66d8f93df8e7c1b613cc045217df6ba81c9334af3cf368
data/lib/undertow/dsl.rb CHANGED
@@ -21,19 +21,27 @@ module Undertow
21
21
  _undertow_ensure_trackable!
22
22
  end
23
23
 
24
+ # Suppress self-tracking when these root-model columns are the only changes.
25
+ # Accepts strings or symbols; blanks are ignored.
24
26
  def undertow_skip(columns)
25
- _undertow_config.skip_columns = columns
27
+ _undertow_config.skip_columns = Array(columns).map(&:to_s).reject(&:blank?).uniq
26
28
  _undertow_ensure_trackable!
27
29
  end
28
30
 
31
+ # Track invalidations from an upstream association.
32
+ # `watched_columns` accepts strings or symbols; blanks are ignored.
33
+ # Empty/nil means no watched-column filter.
29
34
  def undertow_depends_on(association, foreign_key: nil, resolver: nil, watched_columns: nil)
30
35
  raise ArgumentError, 'provide exactly one of foreign_key: or resolver:' unless foreign_key.nil? ^ resolver.nil?
31
36
 
37
+ normalized_watched = Array(watched_columns).map(&:to_s).
38
+ reject(&:blank?).uniq.presence
39
+
32
40
  _undertow_config.dependencies << {
33
41
  association: association,
34
42
  foreign_key: foreign_key,
35
43
  resolver: resolver,
36
- watched_columns: watched_columns
44
+ watched_columns: normalized_watched
37
45
  }.freeze
38
46
  _undertow_ensure_trackable!
39
47
  end
@@ -4,8 +4,9 @@ module Undertow
4
4
  # ActiveRecord concern mixed in automatically when a model uses the Undertow DSL
5
5
  # (undertow_on_drain, undertow_skip, undertow_depends_on). Never included manually.
6
6
  #
7
- # Provides class-level callback registration and the skip_columns guard.
8
- # Callbacks are wired at boot by the Railtie after all models are loaded.
7
+ # Provides class-level callback registration and dependency push handlers, plus
8
+ # instance-level self-tracking handlers. Callbacks are wired at boot by the Railtie
9
+ # after all models are loaded.
9
10
  module Trackable
10
11
  extend ActiveSupport::Concern
11
12
 
@@ -30,86 +31,100 @@ module Undertow
30
31
  (config.dependencies || []).each { |dep| _register_dep_callbacks!(dep) }
31
32
  end
32
33
 
34
+ def _push_undertow_pending(ids)
35
+ Buffer.push_pending(name, ids)
36
+ end
37
+
38
+ def _push_undertow_deleted(ids)
39
+ Buffer.push_deleted(name, ids)
40
+ end
41
+
42
+ def _push_dep_pending(record, dep)
43
+ ids = _dep_ids_for(record, dep)
44
+ _push_undertow_pending(ids) if ids.any?
45
+ end
46
+
47
+ def _push_dep_updated(record, dep)
48
+ watched = dep[:watched_columns]
49
+ # Two non-obvious cases where after_commit on :update fires:
50
+ # - no-op save: saved_changes is {}, suppressed when watched_columns is configured
51
+ # - touch (e.g. belongs_to touch: true): saved_changes contains only updated_at,
52
+ # suppressed only when watched_columns is configured and updated_at is not in it
53
+ return if watched && (record.saved_changes.keys & watched).none?
54
+
55
+ _push_dep_pending(record, dep)
56
+ end
57
+
33
58
  private
34
59
 
35
60
  def _register_self_callbacks!
36
- after_commit :_push_self_pending, on: %i[create update]
61
+ after_commit :_push_self_created, on: :create
62
+ after_commit :_push_self_updated, on: :update
37
63
  after_destroy :_push_self_deleted
38
- after_restore :_push_self_pending if respond_to?(:after_restore)
64
+ after_restore :_push_self_restored if respond_to?(:after_restore)
39
65
  end
40
66
 
41
67
  def _register_dep_callbacks!(dep)
42
68
  dep_class = _resolve_dep_class(dep)
43
- return unless dep_class
44
-
45
69
  root_class = self
46
- watched = dep[:watched_columns].presence # [] treated same as nil, watch all
47
-
48
- resolver = dep[:resolver] || begin
49
- fk = dep[:foreign_key]
50
- ->(record) { root_class.where(fk => record.id) }
51
- end
52
-
53
- push_pending = ->(record) {
54
- ids = resolver.call(record).pluck(:id)
55
- next unless ids.any?
56
70
 
57
- root_class._push_undertow_pending(ids)
58
- }
71
+ dep_class.after_commit(on: :create) { root_class._push_dep_pending(self, dep) }
72
+ dep_class.after_commit(on: :update) { root_class._push_dep_updated(self, dep) }
59
73
 
60
- # Skip create/update callback when watched_columns is set and none changed.
61
- # Note: saved_changes is empty when touched via belongs_to touch: true (bypasses
62
- # dirty tracking), that correctly falls through to skip here.
63
- dep_class.after_commit on: %i[create update] do
64
- next if watched && (saved_changes.keys & watched).none?
74
+ # Soft-delete gems fire run_callbacks(:destroy), triggering after_destroy, but
75
+ # mark the record deleted via update_columns (bypassing after_commit),
76
+ # so the create/update callbacks above never double-fire on a soft delete.
77
+ dep_class.after_destroy { root_class._push_dep_pending(self, dep) }
65
78
 
66
- push_pending.call(self)
67
- end
68
-
69
- # Dep destroyed, reindex surviving root records. SoftDeletable calls
70
- # run_callbacks(:destroy), which fires after_destroy, but update_columns does NOT
71
- # trigger after_commit, so scoping after_commit to [:create, :update] above
72
- # ensures destroy commits don't double-fire.
73
- dep_class.after_destroy { push_pending.call(self) }
74
-
75
- # Dep restored, after_restore is the only hook that fires because restore!
76
- # uses update_columns, bypassing after_commit.
77
- if dep_class.respond_to?(:after_restore)
78
- dep_class.after_restore { push_pending.call(self) }
79
- end
79
+ # Soft-delete restores use update_columns to unmark the record, bypassing
80
+ # after_commit. after_restore is the only hook that fires for restores.
81
+ dep_class.after_restore { root_class._push_dep_pending(self, dep) } if dep_class.respond_to?(:after_restore)
80
82
  end
81
83
 
82
84
  def _resolve_dep_class(dep)
83
- if dep[:resolver]
85
+ reflect_on_association(dep[:association])&.klass ||
84
86
  dep[:association].to_s.classify.constantize
87
+ end
88
+
89
+ def _dep_ids_for(record, dep)
90
+ if dep[:resolver]
91
+ dep[:resolver].call(record).pluck(:id)
85
92
  else
86
- reflect_on_association(dep[:association])&.klass ||
87
- dep[:association].to_s.classify.constantize
93
+ where(dep[:foreign_key] => record.id).pluck(:id)
88
94
  end
89
95
  end
96
+ end
90
97
 
91
- public
92
-
93
- def _push_undertow_pending(ids)
94
- Buffer.push_pending(name, ids)
95
- end
98
+ private
96
99
 
97
- def _push_undertow_deleted(ids)
98
- Buffer.push_deleted(name, ids)
99
- end
100
+ def _push_self_created
101
+ _enqueue_self_pending
100
102
  end
101
103
 
102
- private
104
+ def _push_self_updated
105
+ return unless _self_update_requires_invalidation?
103
106
 
104
- def _push_self_pending
105
- ignored = self.class._undertow_ignored_columns
106
- return if ignored.any? && saved_changes.any? && (saved_changes.keys - ignored).empty?
107
+ _enqueue_self_pending
108
+ end
107
109
 
108
- self.class._push_undertow_pending([id])
110
+ def _push_self_restored
111
+ _enqueue_self_pending
109
112
  end
110
113
 
111
114
  def _push_self_deleted
112
115
  self.class._push_undertow_deleted([id])
113
116
  end
117
+
118
+ def _enqueue_self_pending
119
+ self.class._push_undertow_pending([id])
120
+ end
121
+
122
+ def _self_update_requires_invalidation?
123
+ changed = saved_changes.keys
124
+ return false if changed.empty?
125
+
126
+ ignored = self.class._undertow_ignored_columns
127
+ (changed - ignored).any?
128
+ end
114
129
  end
115
130
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Undertow
4
- VERSION = '0.2.0'
4
+ VERSION = '0.2.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: undertow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Allen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord