worker_plugins 0.0.14 → 0.0.15

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: 13eab7a67becfdbd1809b1930d1314a86c6c0cb217d39ae19181e1dec7453976
4
- data.tar.gz: 65b838cc21313b78f92d43b85a95adba991f50360d05be8ec11a2050b4b75133
3
+ metadata.gz: 46a8d25556ad9d41f62307376bf457a790ab3e67e934678cac0ad9b754112e8e
4
+ data.tar.gz: 2163df96c0f85a9102f1694fbeda59f06f70c296924e0a9dd9320a0b239e0f15
5
5
  SHA512:
6
- metadata.gz: 46ebc5d54657090702d0280e72c5f0d863effcbe7ea478892f492b81bfba829a478220fddbde9f2c9df9e7dc7efc07fa3ec5518aa6b455de81be41c7297e72d9
7
- data.tar.gz: 4b76bdfa275a1641d782ef84bc96e5e6658d866fb4aef3831f82986dabea510a4e61becb19179bf6a80535181b2492bd939346da8e53c7c110fd36940f181cf7
6
+ metadata.gz: ef92d06893d7b9a1e56ade70c4b78432c0cd3b04544db92c5827865883d97b9912fc392930e53d26324c6bb6dbec23481e101fb4ba0218c51a2e52ef1634c497
7
+ data.tar.gz: 24c4313891e0864b95695af807eca5586739c997039cc3bb54b82050964af2dbd92806c90b3e7077d8acdd93f5b636dd306e6300331961b41ef71d424f8a099a
@@ -8,17 +8,11 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
8
8
  end
9
9
 
10
10
  def perform
11
- created # Cache which are about to be created
12
- add_query_to_workplace
13
- succeed!(created:)
11
+ succeed!(affected_count: add_query_to_workplace)
14
12
  end
15
13
 
16
14
  def add_query_to_workplace
17
- WorkerPlugins::WorkplaceLink.connection.execute(sql)
18
- end
19
-
20
- def created
21
- @created ||= resources_to_add.pluck(primary_key.to_sym)
15
+ WorkerPlugins::WorkplaceLink.connection.exec_update(sql, "WorkerPlugins::AddQuery INSERT", [])
22
16
  end
23
17
 
24
18
  def ids_added_already_query
@@ -40,19 +34,25 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
40
34
  end
41
35
 
42
36
  def primary_key
43
- @primary_key ||= resources_to_add.klass.primary_key
37
+ @primary_key ||= model_class.primary_key
44
38
  end
45
39
 
46
40
  def resources_to_add
47
- # Correlate per row with NOT EXISTS instead of NOT IN + a materialized
48
- # subquery. The old form expanded into a nested `resource_id IN (SELECT
49
- # CAST(users.id AS CHAR) FROM users)` that did a full scan of the target
50
- # table when the outer query was unfiltered 60s+ on 340k+ users. This
51
- # uses the `(workplace_id, resource_type, resource_id)` composite index
52
- # for an index seek per row.
53
- @resources_to_add ||= query
54
- .distinct
55
- .where("NOT EXISTS (#{existing_workplace_link_exists_sql})")
41
+ # The unique index on `(workplace_id, resource_type, resource_id)` lets us
42
+ # skip the `WHERE NOT EXISTS` anti-join for the common unbounded query —
43
+ # duplicates are rejected on INSERT by the dialect-specific conflict
44
+ # clause in #sql. `.distinct` still handles same-row duplicates produced
45
+ # by joins in the caller's query (e.g. `User.joins(:tasks)`).
46
+ #
47
+ # When the caller scopes with `.limit` / `.offset`, we keep the anti-join
48
+ # so already-linked rows are filtered *before* the window is applied;
49
+ # otherwise `Task.limit(100)` could insert fewer than 100 new rows when
50
+ # some of those 100 are already linked.
51
+ @resources_to_add ||= if query.limit_value || query.offset_value
52
+ query.distinct.where("NOT EXISTS (#{existing_workplace_link_exists_sql})")
53
+ else
54
+ query.distinct
55
+ end
56
56
  end
57
57
 
58
58
  def existing_workplace_link_exists_sql
@@ -106,7 +106,7 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
106
106
 
107
107
  def sql
108
108
  @sql ||= "
109
- INSERT INTO
109
+ #{insert_clause} INTO
110
110
  worker_plugins_workplace_links
111
111
 
112
112
  (
@@ -118,6 +118,23 @@ class WorkerPlugins::AddQuery < WorkerPlugins::ApplicationService
118
118
  )
119
119
 
120
120
  #{select_sql}
121
+ #{conflict_clause}
121
122
  "
122
123
  end
124
+
125
+ def insert_clause
126
+ if mysql?
127
+ "INSERT IGNORE"
128
+ elsif sqlite?
129
+ "INSERT OR IGNORE"
130
+ else
131
+ "INSERT"
132
+ end
133
+ end
134
+
135
+ def conflict_clause
136
+ return "" unless postgres?
137
+
138
+ "ON CONFLICT (workplace_id, resource_type, resource_id) DO NOTHING"
139
+ end
123
140
  end
@@ -1,17 +1,37 @@
1
1
  class WorkerPlugins::RemoveQuery < WorkerPlugins::ApplicationService
2
2
  arguments :query, :workplace
3
3
 
4
- attr_reader :destroyed
5
-
6
4
  def perform
7
- remove_query_from_workplace
8
- succeed!(destroyed:, mode: :destroyed)
5
+ succeed!(affected_count: links_scope.delete_all)
6
+ end
7
+
8
+ def links_scope
9
+ scope = workplace.workplace_links.where(resource_type: model_class.name)
10
+ return scope if unscoped_query?
11
+
12
+ scope.where(resource_id: query_with_selected_ids)
9
13
  end
10
14
 
11
- def remove_query_from_workplace
12
- links_query = workplace.workplace_links.where(resource_type: model_class.name, resource_id: query_with_selected_ids)
13
- @destroyed = links_query.pluck(:resource_id)
14
- links_query.delete_all
15
+ # If the caller's query has no meaningful scoping applied, the `resource_id
16
+ # IN (SELECT ... FROM <target_table>)` subquery would simply materialize
17
+ # every row of the target model — for 340k+ users that's a full-table scan
18
+ # with no semantic effect other than preserving orphaned links. The
19
+ # `resource_type = ?` filter alone is enough to pin the DELETE to this
20
+ # workplace's links of the given type, so we short-circuit the subquery in
21
+ # that case. Orphaned links (whose resource row has since been deleted) are
22
+ # deleted alongside live ones, which matches caller intent ("remove
23
+ # everything matching the query") and is the correct thing to do with
24
+ # dead references anyway.
25
+ def unscoped_query?
26
+ @query.where_clause.empty? &&
27
+ @query.joins_values.empty? &&
28
+ @query.left_outer_joins_values.empty? &&
29
+ @query.group_values.empty? &&
30
+ @query.having_clause.empty? &&
31
+ @query.limit_value.nil? &&
32
+ @query.offset_value.nil? &&
33
+ @query.from_clause.value.nil? &&
34
+ @query.with_values.empty?
15
35
  end
16
36
 
17
37
  def model_class
@@ -46,14 +46,24 @@ class WorkerPlugins::SelectColumnWithTypeCast < WorkerPlugins::ApplicationServic
46
46
  end
47
47
 
48
48
  def same_type?
49
- column_to_select.type == column_to_compare_with.type || mysql_string_uuid_compatible_types?
49
+ return true if column_to_select.type == column_to_compare_with.type
50
+
51
+ mysql_implicit_conversion_safe?
50
52
  end
51
53
 
52
- def mysql_string_uuid_compatible_types?
54
+ # On MySQL / MariaDB, implicit conversion handles comparisons between any
55
+ # string-ish column types (VARCHAR, CHAR, BINARY, UUID, or types AR doesn't
56
+ # recognize — e.g. MariaDB's native UUID type when using an older mysql2
57
+ # adapter). The explicit CAST is only needed when one side is numeric,
58
+ # because MySQL would then force a string → number conversion that loses
59
+ # rows containing non-numeric values.
60
+ def mysql_implicit_conversion_safe?
53
61
  return false unless mysql?
54
62
 
55
- types = [column_to_select.type, column_to_compare_with.type]
63
+ !numeric_type?(column_to_select.type) && !numeric_type?(column_to_compare_with.type)
64
+ end
56
65
 
57
- types.include?(:string) && types.include?(:uuid)
66
+ def numeric_type?(type)
67
+ %i[integer decimal float bigint].include?(type)
58
68
  end
59
69
  end
@@ -2,20 +2,30 @@ class WorkerPlugins::SwitchQuery < WorkerPlugins::ApplicationService
2
2
  arguments :query, :workplace
3
3
 
4
4
  def perform
5
- add_query_service = WorkerPlugins::AddQuery.new(query:, workplace:)
6
- created = add_query_service.created
7
-
8
- if created.empty?
9
- result = WorkerPlugins::RemoveQuery.execute!(query:, workplace:)
10
- succeed!(
11
- destroyed: result.fetch(:destroyed),
12
- mode: :destroyed
13
- )
5
+ # Decide mode *before* running the insert. Deciding it from AddQuery's
6
+ # post-insert `affected_count` would make concurrent "add" toggles
7
+ # destructive: if request A's INSERT commits first, overlapping
8
+ # request B would see `affected_count == 0` from its own (no-op)
9
+ # INSERT and flip to RemoveQuery, wiping out what A just added. A
10
+ # pre-insert EXISTS probe keeps the race window small in the same
11
+ # way the previous candidate-pluck approach did, without materializing
12
+ # any ids into Ruby.
13
+ if any_unlinked_candidate?
14
+ add_result = WorkerPlugins::AddQuery.execute!(query:, workplace:)
15
+ succeed!(affected_count: add_result.fetch(:affected_count), mode: :created)
14
16
  else
15
- succeed!(
16
- created: add_query_service.tap(&:add_query_to_workplace).created,
17
- mode: :created
18
- )
17
+ remove_result = WorkerPlugins::RemoveQuery.execute!(query:, workplace:)
18
+ succeed!(affected_count: remove_result.fetch(:affected_count), mode: :destroyed)
19
19
  end
20
20
  end
21
+
22
+ def any_unlinked_candidate?
23
+ add_service = WorkerPlugins::AddQuery.new(query:, workplace:)
24
+
25
+ add_service
26
+ .query
27
+ .distinct
28
+ .where("NOT EXISTS (#{add_service.existing_workplace_link_exists_sql})")
29
+ .exists?
30
+ end
21
31
  end
@@ -1,3 +1,3 @@
1
1
  module WorkerPlugins
2
- VERSION = "0.0.14".freeze
2
+ VERSION = "0.0.15".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: worker_plugins
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kasper Stöckel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-22 00:00:00.000000000 Z
11
+ date: 2026-04-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Rails framework for easily choosing and creating lists of objects and
14
14
  execute plugins against them.