upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require "pathname"
6
+
7
+ module Upkeep
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_subscription_migration
18
+ return if migration_exists?("create_upkeep_subscriptions")
19
+
20
+ @migration_version = ActiveRecord::Migration.current_version
21
+ migration_template "create_upkeep_subscriptions.rb.erb", "db/migrate/create_upkeep_subscriptions.rb"
22
+ end
23
+
24
+ def create_initializer
25
+ template "upkeep.rb", "config/initializers/upkeep.rb"
26
+ end
27
+
28
+ def create_browser_bootstrap
29
+ template "subscription.js", "app/javascript/upkeep/subscription.js"
30
+ append_application_import
31
+ pin_action_cable
32
+ end
33
+
34
+ def mount_action_cable
35
+ return if routes_path.exist? && routes_path.read.include?("ActionCable.server")
36
+
37
+ route %(mount ActionCable.server => "/cable")
38
+ end
39
+
40
+ def show_identity_setup_guidance
41
+ usages = detected_identity_usages
42
+ return if usages.empty?
43
+
44
+ say "\nIdentity setup required", :yellow
45
+ say "Upkeep found request-side identity usage:"
46
+ usages.each { |usage| say " #{usage}" }
47
+ say "Upkeep does not infer subscriber identity by naming convention."
48
+ say "Add an explicit Upkeep::Rails.configure identity mapping in config/initializers/upkeep.rb."
49
+ say "Pages that depend on undeclared non-absent CurrentAttributes or Warden identities are refused for live updates."
50
+ say ""
51
+ end
52
+
53
+ private
54
+
55
+ def migration_exists?(name)
56
+ Dir.glob(destination_path("db/migrate/*.rb")).any? do |path|
57
+ File.basename(path).include?(name)
58
+ end
59
+ end
60
+
61
+ def append_application_import
62
+ return unless application_js_path.exist?
63
+
64
+ append_import("@hotwired/turbo-rails")
65
+ append_import("upkeep/subscription")
66
+ end
67
+
68
+ def pin_action_cable
69
+ return unless importmap_path.exist?
70
+
71
+ pin_importmap("@hotwired/turbo-rails", "turbo.min.js")
72
+ pin_importmap("@rails/actioncable", "actioncable.esm.js")
73
+ pin_importmap("upkeep/subscription", "upkeep/subscription.js")
74
+ end
75
+
76
+ def append_import(specifier)
77
+ return if application_js_path.read.include?(specifier)
78
+
79
+ append_to_file application_js_path.to_s, %(import "#{specifier}"\n)
80
+ end
81
+
82
+ def pin_importmap(specifier, asset)
83
+ return if importmap_path.read.include?(%("#{specifier}"))
84
+
85
+ append_to_file importmap_path.to_s, %(pin "#{specifier}", to: "#{asset}"\n)
86
+ end
87
+
88
+ def detected_identity_usages
89
+ usages = []
90
+ source = application_source
91
+ usages << "Current.user" if source.match?(/\bCurrent\.user\b/)
92
+ usages << "session[:user_id]" if source.match?(/\bsession\[(?::user_id|['"]user_id['"])\]/)
93
+ usages << "warden.user" if source.match?(/\bwarden\.user\b/)
94
+ usages.uniq
95
+ end
96
+
97
+ def application_source
98
+ app_paths.filter_map do |path|
99
+ File.read(path)
100
+ rescue StandardError
101
+ nil
102
+ end.join("\n")
103
+ end
104
+
105
+ def app_paths
106
+ Dir.glob(destination_path("app/**/*")).select do |path|
107
+ File.file?(path) && %w[.rb .erb].include?(File.extname(path))
108
+ end
109
+ end
110
+
111
+ def routes_path
112
+ Pathname(destination_path("config/routes.rb"))
113
+ end
114
+
115
+ def application_js_path
116
+ Pathname(destination_path("app/javascript/application.js"))
117
+ end
118
+
119
+ def importmap_path
120
+ Pathname(destination_path("config/importmap.rb"))
121
+ end
122
+
123
+ def destination_path(path)
124
+ File.join(destination_root, path)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,49 @@
1
+ class CreateUpkeepSubscriptions < ActiveRecord::Migration[<%= @migration_version %>]
2
+ def change
3
+ create_table :upkeep_subscriptions, id: :string do |t|
4
+ t.string :subscriber_id, null: false
5
+ t.json :recorder_snapshot, null: false
6
+ t.json :metadata
7
+ t.string :subscription_shape_key
8
+ t.timestamps
9
+ end
10
+ add_index :upkeep_subscriptions, :subscriber_id
11
+ add_index :upkeep_subscriptions, :subscription_shape_key, name: "idx_upkeep_subscriptions_on_shape_key"
12
+
13
+ create_table :upkeep_subscription_index_entries do |t|
14
+ t.string :subscription_id, null: false
15
+ t.string :lookup_key_digest, null: false
16
+ t.string :dependency_source, null: false
17
+ t.string :lookup_table, null: false
18
+ t.json :lookup_record_id_snapshot
19
+ t.string :lookup_attribute, null: false
20
+ t.string :dependency_table, null: false
21
+ t.string :dependency_predicate_digest
22
+ t.json :dependency_metadata_snapshot
23
+ t.json :owner_ids_snapshot, null: false
24
+ t.timestamps
25
+ end
26
+ add_index :upkeep_subscription_index_entries, :subscription_id
27
+ add_index :upkeep_subscription_index_entries, :lookup_key_digest
28
+ add_foreign_key :upkeep_subscription_index_entries,
29
+ :upkeep_subscriptions,
30
+ column: :subscription_id,
31
+ on_delete: :cascade
32
+
33
+ create_table :upkeep_subscription_shape_index_entries do |t|
34
+ t.string :subscription_shape_key, null: false
35
+ t.string :lookup_key_digest, null: false
36
+ t.string :dependency_source, null: false
37
+ t.string :lookup_table, null: false
38
+ t.json :lookup_record_id_snapshot
39
+ t.string :lookup_attribute, null: false
40
+ t.string :dependency_table, null: false
41
+ t.string :dependency_predicate_digest
42
+ t.json :dependency_metadata_snapshot
43
+ t.json :owner_ids_snapshot, null: false
44
+ t.timestamps
45
+ end
46
+ add_index :upkeep_subscription_shape_index_entries, :subscription_shape_key, name: "idx_upkeep_sub_shape_entries_on_shape_key"
47
+ add_index :upkeep_subscription_shape_index_entries, :lookup_key_digest, name: "idx_upkeep_sub_shape_entries_on_lookup_digest"
48
+ end
49
+ end
@@ -0,0 +1,99 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+ import { Turbo } from "@hotwired/turbo-rails"
3
+
4
+ const SOURCE_ELEMENT = "upkeep-subscription-source"
5
+ const SOURCE_SELECTOR = `${SOURCE_ELEMENT}[data-upkeep-subscription]`
6
+
7
+ let consumer
8
+
9
+ function cableConsumer() {
10
+ consumer ||= createConsumer()
11
+ return consumer
12
+ }
13
+
14
+ function parsePayload(element) {
15
+ try {
16
+ return JSON.parse(element.textContent || "{}")
17
+ } catch {
18
+ return {}
19
+ }
20
+ }
21
+
22
+ function renderStreamMessage(data) {
23
+ if (Turbo?.renderStreamMessage) {
24
+ Turbo.renderStreamMessage(String(data))
25
+ }
26
+ }
27
+
28
+ class UpkeepSubscriptionSourceElement extends HTMLElement {
29
+ connectedCallback() {
30
+ this.connectStreamSource()
31
+ this.subscribe()
32
+ }
33
+
34
+ disconnectedCallback() {
35
+ this.unsubscribe()
36
+ this.disconnectStreamSource()
37
+ }
38
+
39
+ connectStreamSource() {
40
+ if (this.streamSourceConnected) return
41
+ if (!Turbo?.session?.connectStreamSource) return
42
+
43
+ Turbo.session.connectStreamSource(this)
44
+ this.streamSourceConnected = true
45
+ }
46
+
47
+ disconnectStreamSource() {
48
+ if (!this.streamSourceConnected) return
49
+
50
+ Turbo.session.disconnectStreamSource(this)
51
+ this.streamSourceConnected = false
52
+ }
53
+
54
+ subscribe() {
55
+ const payload = parsePayload(this)
56
+ if (!payload.subscription_id || this.subscription) return
57
+
58
+ this.subscription = cableConsumer().subscriptions.create(
59
+ {
60
+ channel: payload.channel || "Upkeep::Rails::Cable::Channel",
61
+ subscription_id: payload.subscription_id,
62
+ activation_token: payload.activation_token
63
+ },
64
+ {
65
+ received: (data) => this.receive(data),
66
+
67
+ rejected: () => {
68
+ console.error(
69
+ "[upkeep] subscription rejected by the server; refresh app/javascript/upkeep/subscription.js if this started after upgrading upkeep-rails",
70
+ { subscription_id: payload.subscription_id, channel: payload.channel || "Upkeep::Rails::Cable::Channel" }
71
+ )
72
+ }
73
+ }
74
+ )
75
+ }
76
+
77
+ unsubscribe() {
78
+ if (!this.subscription) return
79
+
80
+ this.subscription.unsubscribe()
81
+ this.subscription = null
82
+ }
83
+
84
+ receive(data) {
85
+ if (this.streamSourceConnected) {
86
+ this.dispatchEvent(new MessageEvent("message", { data: String(data) }))
87
+ } else {
88
+ renderStreamMessage(data)
89
+ }
90
+ }
91
+ }
92
+
93
+ if (!customElements.get(SOURCE_ELEMENT)) {
94
+ customElements.define(SOURCE_ELEMENT, UpkeepSubscriptionSourceElement)
95
+ }
96
+
97
+ export function connectUpkeepSubscriptions() {
98
+ document.querySelectorAll(SOURCE_SELECTOR).forEach((source) => source.subscribe?.())
99
+ }
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ Upkeep::Rails.configure do |config|
4
+ app_config = Rails.application.config.upkeep
5
+
6
+ config.enabled = app_config.fetch(:enabled, true)
7
+ config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
8
+ config.delivery_adapter = app_config.fetch(:delivery_adapter, Rails.env.production? ? :active_job : :async)
9
+ config.delivery_queue = app_config.fetch(:delivery_queue, :upkeep_realtime)
10
+
11
+ # Delivery setup:
12
+ # Upkeep uses Active Job for committed-change delivery in production and async
13
+ # in development/test. Configure your app's Active Job backend normally
14
+ # (Solid Queue, Sidekiq, GoodJob, etc.) and configure ActionCable with a shared
15
+ # adapter such as Solid Cable, Redis, or PostgreSQL so worker broadcasts can
16
+ # reach web socket connections.
17
+ #
18
+ # Test setup:
19
+ # The generated test default is the in-process memory store. It follows the
20
+ # same subscription lifecycle as ActiveRecord without requiring subscription
21
+ # tables in every app test database. Keep at least one app/CI path on
22
+ # :active_record when you want to exercise durable subscription rows.
23
+ #
24
+ # View setup:
25
+ # No per-template annotations are required for ordinary Rails views. Upkeep
26
+ # instruments Action View templates as they render and adds the internal
27
+ # markers it needs for page roots, fragment roots, and safe partial collection
28
+ # render-site containers. Keep rendering normal ERB and collection partials:
29
+ #
30
+ # <ul>
31
+ # <%%= render partial: "cards/card", collection: @cards, as: :card %>
32
+ # </ul>
33
+ #
34
+ # The upkeep_frame helper remains available for advanced/generated boundaries
35
+ # that cannot be derived from template source, but it is not part of normal
36
+ # application setup.
37
+
38
+ # Identity setup:
39
+ # Upkeep does not infer subscriber identity by naming convention. Declare each
40
+ # identity boundary that should partition live updates, and resolve the same
41
+ # boundary from ActionCable when the browser subscribes.
42
+ #
43
+ # Example for Current.user plus ApplicationCable identified_by :current_user:
44
+ #
45
+ # config.identify :viewer, current: ["Current", :user] do
46
+ # subscribe { |connection| connection.current_user }
47
+ # end
48
+ #
49
+ # Example for Devise/Warden current_user:
50
+ #
51
+ # config.identify :viewer, warden: :user do
52
+ # subscribe { |connection| connection.current_user }
53
+ # end
54
+ #
55
+ # Example for session-backed authentication:
56
+ #
57
+ # config.identify :viewer, session: :user_id do
58
+ # # nil means this identity boundary is absent. Use absent_if for any
59
+ # # additional app-specific sentinel, such as false or "guest".
60
+ # # absent_if { |value| value.nil? || value == false }
61
+ # subscribe { |connection| connection.session[:user_id] }
62
+ # end
63
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Upkeep
6
+ module ActiveRecordQuery
7
+ class OpaqueRelationError < StandardError
8
+ attr_reader :model_name, :table_name, :sql, :reasons
9
+
10
+ def initialize(relation, reasons:)
11
+ @model_name = relation.klass.name
12
+ @table_name = relation.klass.table_name
13
+ @sql = relation.to_sql
14
+ @reasons = reasons
15
+
16
+ super(build_message)
17
+ rescue StandardError => error
18
+ super("Upkeep cannot prove this Active Record relation's structural dependencies: #{error.message}")
19
+ end
20
+
21
+ private
22
+
23
+ def build_message
24
+ <<~MESSAGE
25
+ Upkeep cannot make this Active Record relation reactive because its query shape is opaque.
26
+
27
+ Relation:
28
+ #{model_name} (#{table_name})
29
+
30
+ SQL:
31
+ #{sql}
32
+
33
+ Why:
34
+ #{reasons.map { |reason| " - #{reason}" }.join("\n")}
35
+
36
+ What to do:
37
+ - Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.
38
+ - Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.
39
+ - Render this boundary outside Upkeep reactivity when the query cannot expose its sources.
40
+ MESSAGE
41
+ end
42
+
43
+ public
44
+
45
+ def suggestions
46
+ [
47
+ "Rewrite raw SQL predicates with structural Active Record hash or Arel predicates.",
48
+ "Rewrite raw SQL joins or FROM sources with structural Active Record/Arel joins.",
49
+ "Render this boundary outside Upkeep reactivity when the query cannot expose its sources."
50
+ ]
51
+ end
52
+ end
53
+
54
+ Result = Data.define(
55
+ :primary_table,
56
+ :table_columns,
57
+ :coverage,
58
+ :sql,
59
+ :primary_key,
60
+ :appendable,
61
+ :limit_value,
62
+ :predicates
63
+ ) do
64
+ def tables = table_columns.keys.sort
65
+
66
+ def appendable?
67
+ appendable
68
+ end
69
+ end
70
+
71
+ module_function
72
+
73
+ def analyze(relation, opaque_table_policy: :raise)
74
+ collector = Collector.new(relation, opaque_table_policy: opaque_table_policy)
75
+ collector.analyze
76
+ end
77
+
78
+ class Collector
79
+ def initialize(relation, opaque_table_policy:)
80
+ @relation = relation
81
+ @opaque_table_policy = opaque_table_policy
82
+ @primary_table = relation.klass.table_name
83
+ @primary_key = relation.klass.primary_key
84
+ @table_columns = Hash.new { |hash, table| hash[table] = [] }
85
+ @table_aliases = {}
86
+ @opaque_columns = false
87
+ @opaque_tables = false
88
+ @opaque_table_reasons = []
89
+ @opaque_column_reasons = []
90
+ @predicates = []
91
+ end
92
+
93
+ def analyze
94
+ table(@primary_table)
95
+ collect_relation_shape
96
+ raise_opaque_relation! if opaque_relation? && @opaque_table_policy == :raise
97
+
98
+ Result.new(
99
+ primary_table: @primary_table,
100
+ table_columns: normalized_table_columns,
101
+ coverage: coverage,
102
+ sql: safe_sql,
103
+ primary_key: @primary_key,
104
+ appendable: appendable_relation?,
105
+ limit_value: @relation.limit_value,
106
+ predicates: normalized_predicates
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def collect_relation_shape
113
+ ast = @relation.arel.ast
114
+
115
+ ast.cores.each do |core|
116
+ walk(core.source, source: true)
117
+ walk(core.wheres)
118
+ walk(core.groups)
119
+ walk(core.havings)
120
+ end
121
+
122
+ walk(ast.orders)
123
+ walk(ast.with) if ast.respond_to?(:with)
124
+ rescue StandardError => error
125
+ opaque_table!("relation AST could not be inspected (#{error.class}: #{error.message})")
126
+ end
127
+
128
+ def coverage
129
+ return :tables if @opaque_tables || @opaque_columns
130
+
131
+ :columns
132
+ end
133
+
134
+ def normalized_table_columns
135
+ table(@primary_table)
136
+ column(@primary_table, @primary_key) if @primary_key
137
+
138
+ @table_columns.transform_values { |columns| columns.compact.uniq.sort }.sort.to_h
139
+ end
140
+
141
+ def appendable_relation?
142
+ return false unless coverage == :columns
143
+ return false if @opaque_tables
144
+ return false if @relation.limit_value || @relation.offset_value
145
+ return false if @relation.distinct_value
146
+ return false if @relation.group_values.any?
147
+ return false if !@relation.having_clause.empty?
148
+
149
+ true
150
+ end
151
+
152
+ def walk(value, source: false)
153
+ case value
154
+ when nil, true, false, Numeric, Symbol, Class, Module
155
+ nil
156
+ when Array
157
+ value.each { |entry| walk(entry, source: source) }
158
+ when Hash
159
+ value.each_value { |entry| walk(entry, source: source) }
160
+ when Arel::Attributes::Attribute
161
+ attribute(value)
162
+ when Arel::Nodes::Equality
163
+ equality_predicate(value)
164
+ walk_arel_node(value, source: source)
165
+ when Arel::Nodes::HomogeneousIn
166
+ homogeneous_in_predicate(value)
167
+ walk_arel_node(value, source: source)
168
+ when Arel::Table
169
+ table(value.name)
170
+ when Arel::Nodes::TableAlias
171
+ table_alias(value)
172
+ when Arel::Nodes::StringJoin
173
+ opaque_table!("raw SQL join")
174
+ when Arel::Nodes::BoundSqlLiteral, Arel::Nodes::SqlLiteral
175
+ source ? opaque_table!("raw SQL source") : opaque_column!("raw SQL predicate or order expression")
176
+ when String
177
+ source ? opaque_table!("string SQL source") : opaque_column!("string SQL predicate or order expression")
178
+ else
179
+ walk_arel_node(value, source: source)
180
+ end
181
+ end
182
+
183
+ def walk_arel_node(value, source:)
184
+ return unless value.is_a?(Arel::Nodes::Node)
185
+
186
+ value.instance_variables.each do |ivar|
187
+ walk(value.instance_variable_get(ivar), source: source)
188
+ end
189
+ end
190
+
191
+ def attribute(value)
192
+ table_name = table_name_for(value.relation)
193
+ return opaque_table!("attribute references an unknown table source") unless table_name
194
+ return if value.name.to_s == "*"
195
+
196
+ column(table_name, value.name)
197
+ end
198
+
199
+ def table_name_for(relation)
200
+ if relation.is_a?(Arel::Nodes::TableAlias)
201
+ table_name_for(relation.left)
202
+ elsif relation.respond_to?(:name)
203
+ name = relation.name.to_s
204
+ @table_aliases.fetch(name, name)
205
+ elsif relation.respond_to?(:left)
206
+ table_name_for(relation.left)
207
+ end
208
+ end
209
+
210
+ def table_alias(value)
211
+ table_name = table_name_for(value.left)
212
+ return opaque_table!("table alias references an unknown table source") unless table_name
213
+
214
+ @table_aliases[value.right.to_s] = table_name
215
+ table(table_name)
216
+ end
217
+
218
+ def opaque_table!(reason)
219
+ @opaque_tables = true
220
+ @opaque_table_reasons << reason
221
+ end
222
+
223
+ def opaque_column!(reason)
224
+ @opaque_columns = true
225
+ @opaque_column_reasons << reason
226
+ end
227
+
228
+ def opaque_relation?
229
+ @opaque_tables || @opaque_columns
230
+ end
231
+
232
+ def raise_opaque_relation!
233
+ raise OpaqueRelationError.new(@relation, reasons: (@opaque_table_reasons + @opaque_column_reasons).uniq)
234
+ end
235
+
236
+ def table(name)
237
+ @table_columns[name.to_s]
238
+ end
239
+
240
+ def column(table_name, column_name)
241
+ @table_columns[table_name.to_s] << column_name.to_s
242
+ end
243
+
244
+ def equality_predicate(node)
245
+ predicate = predicate_for(node.left, "eq", [predicate_value(node.right)])
246
+ @predicates << predicate if predicate
247
+ end
248
+
249
+ def homogeneous_in_predicate(node)
250
+ predicate = predicate_for(node.attribute, "in", Array(node.values).map { |value| predicate_value(value) })
251
+ @predicates << predicate if predicate
252
+ end
253
+
254
+ def predicate_for(attribute, operator, values)
255
+ return unless attribute.is_a?(Arel::Attributes::Attribute)
256
+
257
+ table_name = table_name_for(attribute.relation)
258
+ return unless table_name
259
+
260
+ values = values.compact
261
+ return if values.empty?
262
+
263
+ {
264
+ table: table_name.to_s,
265
+ column: attribute.name.to_s,
266
+ operator: operator,
267
+ values: values.uniq
268
+ }
269
+ end
270
+
271
+ def predicate_value(value)
272
+ if value.respond_to?(:value_for_database)
273
+ value.value_for_database
274
+ elsif value.respond_to?(:value)
275
+ value.value
276
+ elsif value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Symbol)
277
+ value
278
+ end
279
+ end
280
+
281
+ def normalized_predicates
282
+ @predicates
283
+ .uniq
284
+ .sort_by { |predicate| [predicate.fetch(:table), predicate.fetch(:column), predicate.fetch(:operator), predicate.fetch(:values).inspect] }
285
+ end
286
+
287
+ def safe_sql
288
+ @relation.to_sql
289
+ rescue StandardError => error
290
+ "#{error.class}: #{error.message}"
291
+ end
292
+ end
293
+ end
294
+ end