upkeep-rails 0.2.5-aarch64-linux-gnu
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +244 -0
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/how-it-works.md +329 -0
- data/docs/plans/sqlglot-active-record-migration.md +211 -0
- data/docs/plans/turbo-frame-subscription-composition.md +90 -0
- data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
- data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
- data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
- data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
- data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
- data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
- data/lib/generators/upkeep/install/install_generator.rb +192 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +288 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
- data/lib/upkeep/active_record_query.rb +248 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +454 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +48 -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 +339 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +600 -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 +548 -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 +411 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +1007 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +165 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +65 -0
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +252 -0
- data/lib/upkeep/rails/controller_runtime.rb +187 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/job_runtime.rb +43 -0
- data/lib/upkeep/rails/railtie.rb +44 -0
- data/lib/upkeep/rails/replay.rb +244 -0
- data/lib/upkeep/rails/testing.rb +259 -0
- data/lib/upkeep/rails.rb +466 -0
- data/lib/upkeep/replay.rb +462 -0
- data/lib/upkeep/runtime.rb +1276 -0
- data/lib/upkeep/shared_streams.rb +86 -0
- data/lib/upkeep/sql_dependency_analysis.rb +553 -0
- data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
- data/lib/upkeep/sqlglot/native.rb +121 -0
- data/lib/upkeep/sqlglot/native_library.rb +23 -0
- data/lib/upkeep/sqlglot.rb +367 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
- data/lib/upkeep/subscriptions/active_registry.rb +80 -0
- data/lib/upkeep/subscriptions/base_store.rb +110 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
- data/lib/upkeep/subscriptions/shape.rb +117 -0
- data/lib/upkeep/subscriptions/store.rb +349 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +146 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +15 -0
- data/upkeep-rails.gemspec +66 -0
- metadata +327 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module SharedStreams
|
|
7
|
+
PREFIX = "upkeep:shared"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def stream_name(target:, identity_signature:, sharing_signature:, deployment_signature:)
|
|
12
|
+
digest = Digest::SHA256.hexdigest([target.kind, target.id, identity_signature, sharing_signature, deployment_signature].inspect)[0, 32]
|
|
13
|
+
"#{PREFIX}:#{digest}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def signature_for(recipe)
|
|
17
|
+
if recipe.instance_variable_defined?(:@upkeep_shared_stream_signature)
|
|
18
|
+
recipe.instance_variable_get(:@upkeep_shared_stream_signature)
|
|
19
|
+
else
|
|
20
|
+
Digest::SHA256.hexdigest(recipe.to_h.inspect).tap do |signature|
|
|
21
|
+
recipe.instance_variable_set(:@upkeep_shared_stream_signature, signature)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def names_for_subscription(subscription)
|
|
27
|
+
names_for_graph(subscription.graph)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names_for_recorder(recorder)
|
|
31
|
+
names_for_graph(recorder.graph)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def names_for_graph(graph)
|
|
35
|
+
graph.frame_nodes.filter_map do |frame|
|
|
36
|
+
next unless frame.payload.fetch(:kind) == "render_site"
|
|
37
|
+
|
|
38
|
+
recipe = frame.payload[:recipe]
|
|
39
|
+
next unless recipe
|
|
40
|
+
|
|
41
|
+
identity_signature = identity_signature_for(graph, frame.id)
|
|
42
|
+
next unless identity_signature == "public"
|
|
43
|
+
|
|
44
|
+
target = target_for_frame(frame)
|
|
45
|
+
next unless target
|
|
46
|
+
|
|
47
|
+
stream_name(
|
|
48
|
+
target: target,
|
|
49
|
+
identity_signature: identity_signature,
|
|
50
|
+
sharing_signature: signature_for(recipe),
|
|
51
|
+
deployment_signature: deployment_signature_for(graph, frame.id)
|
|
52
|
+
)
|
|
53
|
+
end.uniq.sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def identity_signature_for(graph, frame_id)
|
|
57
|
+
identity_dependencies = graph.contained_node_ids(frame_id)
|
|
58
|
+
.flat_map { |owner_id| graph.dependencies_for(owner_id) }
|
|
59
|
+
.select { |dependency| Dependencies.partitioning_identity?(dependency) }
|
|
60
|
+
.uniq(&:cache_key)
|
|
61
|
+
return "public" if identity_dependencies.empty?
|
|
62
|
+
|
|
63
|
+
Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deployment-stable request reads (host, protocol, ...) do not partition viewers, but
|
|
67
|
+
# their fingerprinted values must still scope the stream: folding them into the name
|
|
68
|
+
# makes cross-host sharing impossible by construction.
|
|
69
|
+
def deployment_signature_for(graph, frame_id)
|
|
70
|
+
deployment_dependencies = graph.contained_node_ids(frame_id)
|
|
71
|
+
.flat_map { |owner_id| graph.dependencies_for(owner_id) }
|
|
72
|
+
.select { |dependency| Dependencies.deployment_stable_request?(dependency) }
|
|
73
|
+
.uniq(&:cache_key)
|
|
74
|
+
return "none" if deployment_dependencies.empty?
|
|
75
|
+
|
|
76
|
+
Digest::SHA256.hexdigest(deployment_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def target_for_frame(frame)
|
|
80
|
+
case frame.payload.fetch(:kind)
|
|
81
|
+
when "render_site"
|
|
82
|
+
Targeting::Target.new("render_site", frame.payload.fetch(:site_id), "shared render-site frame")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Upkeep
|
|
6
|
+
module SQLDependencyAnalysis
|
|
7
|
+
class UnsupportedError < StandardError; end
|
|
8
|
+
|
|
9
|
+
Result = Data.define(
|
|
10
|
+
:table_columns,
|
|
11
|
+
:predicates,
|
|
12
|
+
:equality_edges,
|
|
13
|
+
:limit_value,
|
|
14
|
+
:appendable,
|
|
15
|
+
:warnings
|
|
16
|
+
) do
|
|
17
|
+
def tables = table_columns.keys.sort
|
|
18
|
+
def appendable? = appendable
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Source = Data.define(:physical_tables)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def analyze(statement, schema:, scope: nil)
|
|
26
|
+
Analyzer.new(statement, schema: schema, scope: scope).analyze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Record rendering tracks wildcard attributes separately from collections.
|
|
30
|
+
def preserve_wildcard_projections(original, qualified)
|
|
31
|
+
ProjectionPreserver.new(original, qualified).call
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Analyzer
|
|
35
|
+
def initialize(statement, schema:, scope:)
|
|
36
|
+
@statement = statement
|
|
37
|
+
@scope = scope
|
|
38
|
+
@columns_by_table = normalize_schema(schema)
|
|
39
|
+
@table_columns = Hash.new { |hash, table| hash[table] = Set.new }
|
|
40
|
+
@predicates = []
|
|
41
|
+
@equality_edges = Set.new
|
|
42
|
+
@warnings = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def analyze
|
|
46
|
+
root = @statement["Select"]
|
|
47
|
+
set_operation = @statement["SetOperation"]
|
|
48
|
+
|
|
49
|
+
if root
|
|
50
|
+
process_select(root, outer_sources: {}, visible_ctes: {})
|
|
51
|
+
elsif set_operation
|
|
52
|
+
process_set_operation(
|
|
53
|
+
set_operation,
|
|
54
|
+
outer_sources: {},
|
|
55
|
+
visible_ctes: {}
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
raise UnsupportedError, "expected a SELECT or set-operation statement"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
validate_scope!
|
|
62
|
+
|
|
63
|
+
Result.new(
|
|
64
|
+
table_columns: normalized_table_columns,
|
|
65
|
+
predicates: normalized_predicates,
|
|
66
|
+
equality_edges: @equality_edges.to_a.sort,
|
|
67
|
+
limit_value: literal_value((root || set_operation)["limit"]),
|
|
68
|
+
appendable: root ? appendable?(root) : false,
|
|
69
|
+
warnings: @warnings.uniq.sort
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_scope!
|
|
76
|
+
return unless @scope
|
|
77
|
+
|
|
78
|
+
ScopeValidator.new(
|
|
79
|
+
@scope,
|
|
80
|
+
columns_by_table: @columns_by_table,
|
|
81
|
+
table_columns: @table_columns
|
|
82
|
+
).validate!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def process_select(select, outer_sources:, visible_ctes:)
|
|
86
|
+
ctes = visible_ctes.dup
|
|
87
|
+
|
|
88
|
+
Array(select["ctes"]).each do |cte|
|
|
89
|
+
before = @table_columns.keys.to_set
|
|
90
|
+
process_query(cte["query"], outer_sources: outer_sources, visible_ctes: ctes)
|
|
91
|
+
physical_tables = @table_columns.keys.to_set - before
|
|
92
|
+
physical_tables.merge(physical_tables_in(cte["query"], ctes))
|
|
93
|
+
ctes[cte["name"]] = Source.new(physical_tables.to_a.sort)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local_sources = source_map(select, ctes, outer_sources)
|
|
97
|
+
sources = outer_sources.merge(local_sources)
|
|
98
|
+
walk_select_body(select, sources: sources, visible_ctes: ctes)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def process_query(query, outer_sources:, visible_ctes:)
|
|
102
|
+
if query.is_a?(Hash) && query.key?("Select")
|
|
103
|
+
process_select(
|
|
104
|
+
query.fetch("Select"),
|
|
105
|
+
outer_sources: outer_sources,
|
|
106
|
+
visible_ctes: visible_ctes
|
|
107
|
+
)
|
|
108
|
+
elsif query.is_a?(Hash) && query.key?("SetOperation")
|
|
109
|
+
process_set_operation(
|
|
110
|
+
query.fetch("SetOperation"),
|
|
111
|
+
outer_sources: outer_sources,
|
|
112
|
+
visible_ctes: visible_ctes
|
|
113
|
+
)
|
|
114
|
+
else
|
|
115
|
+
walk(query, sources: outer_sources, visible_ctes: visible_ctes)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process_set_operation(operation, outer_sources:, visible_ctes:)
|
|
120
|
+
process_query(operation["left"], outer_sources: outer_sources, visible_ctes: visible_ctes)
|
|
121
|
+
process_query(operation["right"], outer_sources: outer_sources, visible_ctes: visible_ctes)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def source_map(select, ctes, outer_sources)
|
|
125
|
+
nodes = [select.dig("from", "source")]
|
|
126
|
+
nodes.concat(Array(select["joins"]).map { |join| join["table"] })
|
|
127
|
+
|
|
128
|
+
nodes.compact.each_with_object({}) do |node, sources|
|
|
129
|
+
if node.is_a?(Hash) && node.key?("Table")
|
|
130
|
+
table = node.fetch("Table")
|
|
131
|
+
name = table["name"]
|
|
132
|
+
physical_name = qualified_table_name(table)
|
|
133
|
+
unless ctes[name] || @columns_by_table.key?(physical_name)
|
|
134
|
+
raise UnsupportedError, "table #{physical_name} is not present in the schema"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
source = ctes[name] || Source.new([physical_name])
|
|
138
|
+
sources[name] = source
|
|
139
|
+
sources[table["alias"]] = source if present?(table["alias"])
|
|
140
|
+
source.physical_tables.each { |physical| @table_columns[physical] }
|
|
141
|
+
elsif node.is_a?(Hash) && node.key?("Subquery")
|
|
142
|
+
subquery = node.fetch("Subquery")
|
|
143
|
+
before = @table_columns.keys.to_set
|
|
144
|
+
process_query(
|
|
145
|
+
subquery["query"],
|
|
146
|
+
outer_sources: outer_sources.merge(sources),
|
|
147
|
+
visible_ctes: ctes
|
|
148
|
+
)
|
|
149
|
+
physical = (@table_columns.keys.to_set - before).to_a
|
|
150
|
+
physical = physical_tables_in(subquery["query"], ctes) if physical.empty?
|
|
151
|
+
alias_name = subquery["alias"]
|
|
152
|
+
sources[alias_name] = Source.new(physical.sort) if present?(alias_name)
|
|
153
|
+
else
|
|
154
|
+
raise UnsupportedError, "unsupported SQL source shape: #{node_shape(node)}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def walk_select_body(select, sources:, visible_ctes:)
|
|
160
|
+
select.each do |key, value|
|
|
161
|
+
next if %w[ctes from where_clause having].include?(key)
|
|
162
|
+
|
|
163
|
+
if key == "joins"
|
|
164
|
+
Array(value).each do |join|
|
|
165
|
+
walk(
|
|
166
|
+
join.reject { |join_key, _value| join_key == "table" },
|
|
167
|
+
sources: sources,
|
|
168
|
+
visible_ctes: visible_ctes
|
|
169
|
+
)
|
|
170
|
+
record_predicates(join["on"], sources)
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
walk(value, sources: sources, visible_ctes: visible_ctes)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
walk(select["where_clause"], sources: sources, visible_ctes: visible_ctes)
|
|
178
|
+
walk(select["having"], sources: sources, visible_ctes: visible_ctes)
|
|
179
|
+
record_predicates(select["where_clause"], sources)
|
|
180
|
+
record_predicates(select["having"], sources)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def walk(node, sources:, visible_ctes:)
|
|
184
|
+
case node
|
|
185
|
+
when Array
|
|
186
|
+
node.each { |child| walk(child, sources: sources, visible_ctes: visible_ctes) }
|
|
187
|
+
when Hash
|
|
188
|
+
if node.key?("Select")
|
|
189
|
+
process_select(
|
|
190
|
+
node.fetch("Select"),
|
|
191
|
+
outer_sources: sources,
|
|
192
|
+
visible_ctes: visible_ctes
|
|
193
|
+
)
|
|
194
|
+
elsif node.key?("SetOperation")
|
|
195
|
+
process_set_operation(
|
|
196
|
+
node.fetch("SetOperation"),
|
|
197
|
+
outer_sources: sources,
|
|
198
|
+
visible_ctes: visible_ctes
|
|
199
|
+
)
|
|
200
|
+
elsif node.key?("Column")
|
|
201
|
+
record_resolved_column(node.fetch("Column"), sources)
|
|
202
|
+
else
|
|
203
|
+
node.each_value do |child|
|
|
204
|
+
walk(child, sources: sources, visible_ctes: visible_ctes)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def record_resolved_column(column, sources)
|
|
211
|
+
name = column.fetch("name")
|
|
212
|
+
candidates = resolved_tables(column, sources)
|
|
213
|
+
|
|
214
|
+
if candidates.one?
|
|
215
|
+
record_column(candidates.first, name)
|
|
216
|
+
elsif candidates.empty?
|
|
217
|
+
raise UnsupportedError,
|
|
218
|
+
"column #{qualified_column_name(column)} has no physical source"
|
|
219
|
+
else
|
|
220
|
+
candidates.each { |table| record_column(table, name) }
|
|
221
|
+
@warnings << "column #{name} is ambiguous; attached to #{candidates.sort.join(', ')}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def resolved_tables(column, sources)
|
|
226
|
+
qualifier = column["table"]
|
|
227
|
+
if present?(qualifier) && sources[qualifier]
|
|
228
|
+
return sources.fetch(qualifier).physical_tables
|
|
229
|
+
end
|
|
230
|
+
return [qualifier] if present?(qualifier) && @columns_by_table.key?(qualifier)
|
|
231
|
+
return [] if present?(qualifier)
|
|
232
|
+
|
|
233
|
+
physical = sources.values.uniq.flat_map(&:physical_tables).uniq
|
|
234
|
+
matches = physical.select do |table|
|
|
235
|
+
@columns_by_table.fetch(table, Set.new).include?(column["name"])
|
|
236
|
+
end
|
|
237
|
+
matches.empty? ? physical : matches
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def record_predicates(expression, sources)
|
|
241
|
+
groups = dnf(expression)
|
|
242
|
+
|
|
243
|
+
groups.each_with_index do |group, group_index|
|
|
244
|
+
group.each do |term|
|
|
245
|
+
predicate = simple_predicate(term, sources)
|
|
246
|
+
next unless predicate
|
|
247
|
+
|
|
248
|
+
predicate[:group] = group_index if groups.length > 1
|
|
249
|
+
@predicates << predicate
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def dnf(node)
|
|
255
|
+
node = node["Nested"] if node.is_a?(Hash) && node.key?("Nested")
|
|
256
|
+
binary = node["BinaryOp"] if node.is_a?(Hash)
|
|
257
|
+
return [[node]] unless binary && %w[And Or].include?(binary["op"])
|
|
258
|
+
|
|
259
|
+
left = dnf(binary["left"])
|
|
260
|
+
right = dnf(binary["right"])
|
|
261
|
+
binary["op"] == "Or" ? left + right : left.product(right).map { |a, b| a + b }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def simple_predicate(node, sources)
|
|
265
|
+
if node.is_a?(Hash) && node.key?("BinaryOp")
|
|
266
|
+
binary = node.fetch("BinaryOp")
|
|
267
|
+
return binary_predicate(binary, sources) if %w[Eq Neq].include?(binary["op"])
|
|
268
|
+
elsif node.is_a?(Hash) && node.key?("InList")
|
|
269
|
+
list = node.fetch("InList")
|
|
270
|
+
values = Array(list["list"]).map { |value| literal_value(value) }
|
|
271
|
+
return if values.any? { |value| value.equal?(UNKNOWN_LITERAL) }
|
|
272
|
+
|
|
273
|
+
return build_predicate(
|
|
274
|
+
extract_column(list["expr"]),
|
|
275
|
+
list["negated"] ? "not_in" : "in",
|
|
276
|
+
values,
|
|
277
|
+
sources
|
|
278
|
+
)
|
|
279
|
+
elsif node.is_a?(Hash) && node.key?("IsNull")
|
|
280
|
+
null = node.fetch("IsNull")
|
|
281
|
+
return build_predicate(
|
|
282
|
+
extract_column(null["expr"]),
|
|
283
|
+
null["negated"] ? "not_eq" : "eq",
|
|
284
|
+
[nil],
|
|
285
|
+
sources
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def binary_predicate(binary, sources)
|
|
291
|
+
left = extract_column(binary["left"])
|
|
292
|
+
right = extract_column(binary["right"])
|
|
293
|
+
|
|
294
|
+
if left && right
|
|
295
|
+
endpoints = [
|
|
296
|
+
resolved_endpoint(left, sources),
|
|
297
|
+
resolved_endpoint(right, sources)
|
|
298
|
+
]
|
|
299
|
+
@equality_edges << endpoints.sort.join("=") if endpoints.all?
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
column, value_node = left ? [left, binary["right"]] : [right, binary["left"]]
|
|
304
|
+
return unless column
|
|
305
|
+
|
|
306
|
+
value = literal_value(value_node)
|
|
307
|
+
return if value.equal?(UNKNOWN_LITERAL)
|
|
308
|
+
|
|
309
|
+
build_predicate(
|
|
310
|
+
column,
|
|
311
|
+
binary["op"] == "Eq" ? "eq" : "not_eq",
|
|
312
|
+
[value],
|
|
313
|
+
sources
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def build_predicate(column, operator, values, sources)
|
|
318
|
+
return unless column
|
|
319
|
+
|
|
320
|
+
tables = resolved_tables(column, sources)
|
|
321
|
+
return unless tables.one?
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
table: tables.first,
|
|
325
|
+
column: column.fetch("name"),
|
|
326
|
+
operator: operator,
|
|
327
|
+
values: values.uniq
|
|
328
|
+
}
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def resolved_endpoint(column, sources)
|
|
332
|
+
tables = resolved_tables(column, sources)
|
|
333
|
+
"#{tables.first}.#{column.fetch('name')}" if tables.one?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def extract_column(node)
|
|
337
|
+
node["Column"] if node.is_a?(Hash) && node.key?("Column")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
UNKNOWN_LITERAL = Object.new.freeze
|
|
341
|
+
|
|
342
|
+
def literal_value(node)
|
|
343
|
+
return nil if node.nil?
|
|
344
|
+
return UNKNOWN_LITERAL unless node.is_a?(Hash)
|
|
345
|
+
return node["StringLiteral"] if node.key?("StringLiteral")
|
|
346
|
+
return numeric_value(node["Number"]) if node.key?("Number")
|
|
347
|
+
return node["Boolean"] if node.key?("Boolean")
|
|
348
|
+
return nil if node.key?("Null")
|
|
349
|
+
|
|
350
|
+
if node.key?("UnaryOp") && node["UnaryOp"]["op"] == "Minus"
|
|
351
|
+
value = literal_value(node["UnaryOp"]["expr"])
|
|
352
|
+
return value * -1 unless value.equal?(UNKNOWN_LITERAL)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
UNKNOWN_LITERAL
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def numeric_value(value)
|
|
359
|
+
value.include?(".") ? Float(value) : Integer(value)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def appendable?(select)
|
|
363
|
+
!select["distinct"] &&
|
|
364
|
+
Array(select["group_by"]).empty? &&
|
|
365
|
+
select["having"].nil? &&
|
|
366
|
+
select["limit"].nil? &&
|
|
367
|
+
select["offset"].nil? &&
|
|
368
|
+
select["fetch_first"].nil?
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def physical_tables_in(node, visible_ctes)
|
|
372
|
+
values = case node
|
|
373
|
+
when Array
|
|
374
|
+
node.flat_map { |child| physical_tables_in(child, visible_ctes) }
|
|
375
|
+
when Hash
|
|
376
|
+
if node.key?("Table")
|
|
377
|
+
table = node.fetch("Table")
|
|
378
|
+
visible_ctes[table["name"]]&.physical_tables ||
|
|
379
|
+
[qualified_table_name(table)]
|
|
380
|
+
else
|
|
381
|
+
node.values.flat_map { |child| physical_tables_in(child, visible_ctes) }
|
|
382
|
+
end
|
|
383
|
+
else
|
|
384
|
+
[]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
values.to_a.compact.flatten.uniq
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def normalize_schema(schema)
|
|
391
|
+
schema.to_h.each_with_object({}) do |(table, columns), normalized|
|
|
392
|
+
column_names = columns.respond_to?(:keys) ? columns.keys : Array(columns)
|
|
393
|
+
normalized[table.to_s] = column_names.map(&:to_s).to_set.freeze
|
|
394
|
+
end.freeze
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def normalized_table_columns
|
|
398
|
+
@table_columns
|
|
399
|
+
.transform_values { |columns| columns.to_a.sort }
|
|
400
|
+
.sort
|
|
401
|
+
.to_h
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def normalized_predicates
|
|
405
|
+
@predicates
|
|
406
|
+
.uniq
|
|
407
|
+
.sort_by do |predicate|
|
|
408
|
+
[
|
|
409
|
+
predicate.fetch(:group, -1),
|
|
410
|
+
predicate.fetch(:table),
|
|
411
|
+
predicate.fetch(:column),
|
|
412
|
+
predicate.fetch(:operator),
|
|
413
|
+
predicate.fetch(:values).inspect
|
|
414
|
+
]
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def qualified_table_name(table)
|
|
419
|
+
[table["catalog"], table["schema"], table["name"]]
|
|
420
|
+
.compact
|
|
421
|
+
.reject(&:empty?)
|
|
422
|
+
.join(".")
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def qualified_column_name(column)
|
|
426
|
+
[column["table"], column["name"]].compact.join(".")
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def node_shape(node)
|
|
430
|
+
node.is_a?(Hash) ? node.keys.first : node.class.name
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def record_column(table, column)
|
|
434
|
+
@table_columns[table.to_s] << column.to_s
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def present?(value)
|
|
438
|
+
!value.nil? && !value.empty?
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
class ProjectionPreserver
|
|
443
|
+
def initialize(original, qualified)
|
|
444
|
+
@original = original
|
|
445
|
+
@qualified = qualified
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def call
|
|
449
|
+
restore(@original, @qualified)
|
|
450
|
+
@qualified
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
private
|
|
454
|
+
|
|
455
|
+
def restore(original, qualified)
|
|
456
|
+
case original
|
|
457
|
+
when Array
|
|
458
|
+
return unless qualified.is_a?(Array) && original.length == qualified.length
|
|
459
|
+
|
|
460
|
+
original.zip(qualified).each { |left, right| restore(left, right) }
|
|
461
|
+
when Hash
|
|
462
|
+
return unless qualified.is_a?(Hash)
|
|
463
|
+
|
|
464
|
+
if original.key?("Select") && qualified.key?("Select")
|
|
465
|
+
restore_select(original.fetch("Select"), qualified.fetch("Select"))
|
|
466
|
+
else
|
|
467
|
+
original.each do |key, value|
|
|
468
|
+
restore(value, qualified[key]) if qualified.key?(key)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def restore_select(original, qualified)
|
|
475
|
+
original_columns = Array(original["columns"])
|
|
476
|
+
if original_columns.any? { |column| wildcard?(column) }
|
|
477
|
+
qualified["columns"] = original_columns
|
|
478
|
+
else
|
|
479
|
+
restore(original_columns, qualified["columns"])
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
original.each do |key, value|
|
|
483
|
+
next if key == "columns"
|
|
484
|
+
|
|
485
|
+
restore(value, qualified[key]) if qualified.key?(key)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def wildcard?(node)
|
|
490
|
+
node.is_a?(Hash) &&
|
|
491
|
+
(node.key?("Wildcard") || node.key?("QualifiedWildcard"))
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
private_constant :ProjectionPreserver
|
|
495
|
+
|
|
496
|
+
class ScopeValidator
|
|
497
|
+
def initialize(scope, columns_by_table:, table_columns:)
|
|
498
|
+
@scope = scope
|
|
499
|
+
@columns_by_table = columns_by_table
|
|
500
|
+
@table_columns = table_columns
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def validate!
|
|
504
|
+
@scope.walk.each do |scope|
|
|
505
|
+
validate_sources(scope)
|
|
506
|
+
validate_columns(scope)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
private
|
|
511
|
+
|
|
512
|
+
def validate_sources(scope)
|
|
513
|
+
scope.sources.each_value do |source|
|
|
514
|
+
physical_tables(source).each do |table|
|
|
515
|
+
next if @table_columns.key?(table)
|
|
516
|
+
|
|
517
|
+
raise UnsupportedError,
|
|
518
|
+
"SQLGlot scope discovered physical table #{table} without a dependency"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def validate_columns(scope)
|
|
524
|
+
scope.columns.each do |column|
|
|
525
|
+
next if column.name == "*" || column.table.nil?
|
|
526
|
+
|
|
527
|
+
source = scope.sources[column.table]
|
|
528
|
+
next unless source
|
|
529
|
+
|
|
530
|
+
physical_tables(source).each do |table|
|
|
531
|
+
next unless @columns_by_table.fetch(table, Set.new).include?(column.name)
|
|
532
|
+
next if @table_columns.fetch(table, Set.new).include?(column.name)
|
|
533
|
+
|
|
534
|
+
raise UnsupportedError,
|
|
535
|
+
"SQLGlot scope discovered #{table}.#{column.name} without a dependency"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def physical_tables(source)
|
|
541
|
+
if source.kind == :table
|
|
542
|
+
table = source.table.qualified_name
|
|
543
|
+
@columns_by_table.key?(table) ? [table] : []
|
|
544
|
+
else
|
|
545
|
+
source.scope.walk.flat_map do |scope|
|
|
546
|
+
scope.sources.values.flat_map { |child| physical_tables(child) }
|
|
547
|
+
end.uniq
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
private_constant :ScopeValidator
|
|
552
|
+
end
|
|
553
|
+
end
|
|
Binary file
|