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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +518 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +514 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +821 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- 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
|