@@ -38,6 +38,10 @@ def call
3838 # it on every built/looked-up child.
3939 organization = charge . organization
4040
41+ new_filter_rows = [ ]
42+ new_filter_value_rows = [ ]
43+ new_filter_ids_in_order = [ ]
44+
4145 filters_params . each do |filter_param |
4246 # NOTE: callers pass either string-keyed (plan flow, via with_indifferent_access) or
4347 # symbol-keyed (subscription override flow, via deep_symbolize_keys) values
@@ -46,50 +50,27 @@ def call
4650
4751 # NOTE: since a filter could be a refinement of another one, we have to make sure
4852 # that we are targeting the right one
49- filter = filters_by_values_key [ values_params . sort ]
50- matched_existing_filter = !filter . nil?
51-
52- filter ||= charge . filters . new ( organization_id : charge . organization_id )
53- filter . charge = charge
54- filter . organization = organization
53+ existing_filter = filters_by_values_key [ values_params . sort ]
5554
56- filter . invoice_display_name = filter_param [ :invoice_display_name ]
57- filter . properties = ChargeModels ::FilterPropertiesService . call (
55+ properties = ChargeModels ::FilterPropertiesService . call (
5856 chargeable : charge ,
5957 properties : filter_param [ :properties ] &.deep_symbolize_keys &.except ( :presentation_group_keys )
6058 ) . properties
6159
62- filter . save! if filter . changed?
63-
64- # NOTE: Make sure updated_at is touched even if not changed to keep the right order.
65- filter . touch if touch # rubocop:disable Rails/SkipsModelValidations
66-
67- # NOTE: Create or update the filter values
68- values_params . each do |key , values |
69- billable_metric_filter = billable_metric_filters_by_key [ key ]
70-
71- # NOTE: only look up an existing filter_value if the parent filter came from
72- # the preloaded set. For freshly-created filters, the values collection
73- # is provably empty — querying it on a now-persisted record issues a
74- # wasted SELECT per filter.
75- filter_value = if matched_existing_filter
76- filter . values . to_a . find { |v | v . billable_metric_filter_id == billable_metric_filter &.id }
77- end
78- filter_value ||= filter . values . build ( organization_id : charge . organization_id )
79- filter_value . charge_filter = filter
80- filter_value . billable_metric_filter = billable_metric_filter
81- filter_value . organization = organization
82-
83- filter_value . values = values
84- filter_value . save! if filter_value . changed?
85-
86- # NOTE: Make sure updated_at is touched even if not changed to keep the right order.
87- filter_value . touch if touch # rubocop:disable Rails/SkipsModelValidations
60+ if existing_filter
61+ update_existing_filter (
62+ existing_filter , organization , filter_param , values_params , properties , touch
63+ )
64+ else
65+ accumulate_new_filter (
66+ organization , filter_param , values_params , properties ,
67+ new_filter_rows , new_filter_value_rows , new_filter_ids_in_order
68+ )
8869 end
89-
90- result . filters << filter
9170 end
9271
72+ bulk_insert_new_filters ( new_filter_rows , new_filter_value_rows , new_filter_ids_in_order )
73+
9374 # NOTE: remove old filters that were not created or updated
9475 charge . filters . where . not ( id : result . filters . map ( &:id ) ) . unscope ( :order ) . find_each do
9576 remove_filter ( it )
@@ -107,6 +88,121 @@ def call
10788
10889 attr_reader :charge , :filters_params
10990
91+ def update_existing_filter ( filter , organization , filter_param , values_params , properties , touch )
92+ filter . charge = charge
93+ filter . organization = organization
94+
95+ filter . invoice_display_name = filter_param [ :invoice_display_name ]
96+ filter . properties = properties
97+
98+ filter . save! if filter . changed?
99+
100+ # NOTE: Make sure updated_at is touched even if not changed to keep the right order.
101+ filter . touch if touch # rubocop:disable Rails/SkipsModelValidations
102+
103+ values_params . each do |key , values |
104+ billable_metric_filter = billable_metric_filters_by_key [ key ]
105+
106+ # NOTE: existing filter was preloaded with values, so this in-memory find avoids a SELECT.
107+ filter_value = filter . values . to_a . find { |v | v . billable_metric_filter_id == billable_metric_filter &.id }
108+ filter_value ||= filter . values . build ( organization_id : charge . organization_id )
109+ filter_value . charge_filter = filter
110+ filter_value . billable_metric_filter = billable_metric_filter
111+ filter_value . organization = organization
112+
113+ filter_value . values = values
114+ filter_value . save! if filter_value . changed?
115+
116+ filter_value . touch if touch # rubocop:disable Rails/SkipsModelValidations
117+ end
118+
119+ result . filters << filter
120+ end
121+
122+ def accumulate_new_filter ( organization , filter_param , values_params , properties ,
123+ new_filter_rows , new_filter_value_rows , new_filter_ids_in_order )
124+ # NOTE: pre-generate the UUID so we can wire ChargeFilterValue rows to their parent
125+ # without a round-trip after the ChargeFilter insert_all.
126+ filter_id = SecureRandom . uuid
127+
128+ # NOTE: build an in-memory AR instance only to run validations — we drop it after
129+ # capturing the row hash. Use a standalone ChargeFilter.new (not
130+ # `charge.filters.new`) so the unsaved instance is not appended to the
131+ # parent's in-memory collection, which can interfere with later charge.save!
132+ # in callers like Charges::CreateService.
133+ filter_instance = ChargeFilter . new (
134+ id : filter_id ,
135+ charge_id : charge . id ,
136+ organization_id : charge . organization_id ,
137+ invoice_display_name : filter_param [ :invoice_display_name ] ,
138+ properties : properties
139+ )
140+ filter_instance . charge = charge
141+ filter_instance . organization = organization
142+ filter_instance . validate!
143+
144+ new_filter_rows << {
145+ id : filter_id ,
146+ charge_id : charge . id ,
147+ organization_id : charge . organization_id ,
148+ invoice_display_name : filter_param [ :invoice_display_name ] ,
149+ properties : properties
150+ }
151+ new_filter_ids_in_order << filter_id
152+
153+ values_params . each do |key , values |
154+ billable_metric_filter = billable_metric_filters_by_key [ key ]
155+
156+ value_instance = ChargeFilterValue . new (
157+ organization_id : charge . organization_id ,
158+ values : values
159+ )
160+ value_instance . charge_filter = filter_instance
161+ value_instance . billable_metric_filter = billable_metric_filter
162+ value_instance . organization = organization
163+ value_instance . validate!
164+
165+ new_filter_value_rows << {
166+ charge_filter_id : filter_id ,
167+ billable_metric_filter_id : billable_metric_filter &.id ,
168+ organization_id : charge . organization_id ,
169+ values : values
170+ }
171+ end
172+ end
173+
174+ def bulk_insert_new_filters ( new_filter_rows , new_filter_value_rows , new_filter_ids_in_order )
175+ return if new_filter_rows . empty?
176+
177+ # NOTE: insert_all skips AR callbacks (and therefore updated_at handling), so set
178+ # monotonically-increasing timestamps to preserve the request order under the
179+ # model's `order(updated_at: :asc)` default scope.
180+ now = Time . current
181+ new_filter_rows . each_with_index do |row , idx |
182+ ts = now + ( idx / 1_000_000.0 )
183+ row [ :created_at ] = ts
184+ row [ :updated_at ] = ts
185+ end
186+ ChargeFilter . insert_all! ( new_filter_rows )
187+
188+ if new_filter_value_rows . any?
189+ new_filter_value_rows . each_with_index do |row , idx |
190+ ts = now + ( idx / 1_000_000.0 )
191+ row [ :created_at ] = ts
192+ row [ :updated_at ] = ts
193+ end
194+ ChargeFilterValue . insert_all! ( new_filter_value_rows )
195+ end
196+
197+ # NOTE: re-fetch the inserted filters with their values so callers iterating over
198+ # result.filters see fully-hydrated records.
199+ inserted = charge . filters
200+ . where ( id : new_filter_ids_in_order )
201+ . includes ( values : :billable_metric_filter )
202+ . index_by ( &:id )
203+ new_filter_ids_in_order . each { |id | result . filters << inserted [ id ] }
204+ end
205+
110206 def filters
111207 @filters ||= charge . filters . includes ( values : :billable_metric_filter )
112208 end
0 commit comments