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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +244 -0
  4. data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
  5. data/docs/how-it-works.md +329 -0
  6. data/docs/plans/sqlglot-active-record-migration.md +211 -0
  7. data/docs/plans/turbo-frame-subscription-composition.md +90 -0
  8. data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
  9. data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
  10. data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
  11. data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
  12. data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
  13. data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
  14. data/lib/generators/upkeep/install/install_generator.rb +192 -0
  15. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  16. data/lib/generators/upkeep/install/templates/subscription.js +288 -0
  17. data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
  18. data/lib/upkeep/active_record_query.rb +248 -0
  19. data/lib/upkeep/capture/request.rb +150 -0
  20. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  21. data/lib/upkeep/dag.rb +454 -0
  22. data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
  23. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  24. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  25. data/lib/upkeep/delivery/transport.rb +194 -0
  26. data/lib/upkeep/delivery/turbo_streams.rb +339 -0
  27. data/lib/upkeep/delivery.rb +7 -0
  28. data/lib/upkeep/dependencies.rb +600 -0
  29. data/lib/upkeep/herb/developer_report.rb +135 -0
  30. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  31. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  32. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  33. data/lib/upkeep/herb/template_manifest.rb +548 -0
  34. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  35. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  36. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  37. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  38. data/lib/upkeep/invalidation/planner.rb +411 -0
  39. data/lib/upkeep/invalidation.rb +7 -0
  40. data/lib/upkeep/rails/action_view_capture.rb +1007 -0
  41. data/lib/upkeep/rails/activation_token.rb +55 -0
  42. data/lib/upkeep/rails/cable/channel.rb +165 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +65 -0
  46. data/lib/upkeep/rails/cluster_guard.rb +57 -0
  47. data/lib/upkeep/rails/configuration.rb +252 -0
  48. data/lib/upkeep/rails/controller_runtime.rb +187 -0
  49. data/lib/upkeep/rails/install.rb +28 -0
  50. data/lib/upkeep/rails/job_runtime.rb +43 -0
  51. data/lib/upkeep/rails/railtie.rb +44 -0
  52. data/lib/upkeep/rails/replay.rb +244 -0
  53. data/lib/upkeep/rails/testing.rb +259 -0
  54. data/lib/upkeep/rails.rb +466 -0
  55. data/lib/upkeep/replay.rb +462 -0
  56. data/lib/upkeep/runtime.rb +1276 -0
  57. data/lib/upkeep/shared_streams.rb +86 -0
  58. data/lib/upkeep/sql_dependency_analysis.rb +553 -0
  59. data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
  60. data/lib/upkeep/sqlglot/native.rb +121 -0
  61. data/lib/upkeep/sqlglot/native_library.rb +23 -0
  62. data/lib/upkeep/sqlglot.rb +367 -0
  63. data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
  64. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
  65. data/lib/upkeep/subscriptions/active_registry.rb +80 -0
  66. data/lib/upkeep/subscriptions/base_store.rb +110 -0
  67. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  68. data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
  69. data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
  70. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
  71. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  72. data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
  73. data/lib/upkeep/subscriptions/shape.rb +117 -0
  74. data/lib/upkeep/subscriptions/store.rb +349 -0
  75. data/lib/upkeep/subscriptions.rb +7 -0
  76. data/lib/upkeep/targeting.rb +146 -0
  77. data/lib/upkeep/version.rb +5 -0
  78. data/lib/upkeep-rails.rb +3 -0
  79. data/lib/upkeep.rb +15 -0
  80. data/upkeep-rails.gemspec +66 -0
  81. 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